connector: add LDAP connector
Authentication is performed by binding to the configured LDAP server using the user supplied credentials. Successfull bind equals authenticated user. Optionally the connector can be configured to search before authentication. The entryDN found will be used to bind to the LDAP server. This feature must be enabled to get supplementary information from the directory (ID, Name, Email). This feature can also be used to limit access to the service. Example use case: Allow your users to log in with e-mail address instead of the identification string in your DNs (typically username). To make re-use of HTTP form handling code from the Local connector possible: - Implemented IdentityProvider interface - Moved the re-used functions to login_local.go Fixes #119
This commit is contained in:
parent
bb53e5bb81
commit
4d970d5fc4
10 changed files with 875 additions and 87 deletions
|
@ -10,15 +10,23 @@ go:
|
|||
|
||||
env:
|
||||
- DEX_TEST_DSN="postgres://postgres@127.0.0.1:15432/postgres?sslmode=disable" ISOLATED=true
|
||||
DEX_TEST_LDAP_URI="ldap://tlstest.local:1389/????bindname=cn%3Dadmin%2Cdc%3Dexample%2Cdc%3Dorg,X-BINDPW=admin"
|
||||
|
||||
install:
|
||||
- go get golang.org/x/tools/cmd/cover
|
||||
- go get golang.org/x/tools/cmd/vet
|
||||
- docker pull quay.io/coreos/postgres
|
||||
- docker pull osixia/openldap
|
||||
|
||||
script:
|
||||
- docker run -d -p 127.0.0.1:15432:5432 quay.io/coreos/postgres
|
||||
- LDAPCONTAINER=`docker run -e LDAP_TLS_PROTOCOL_MIN=3.0 -e LDAP_TLS_CIPHER_SUITE=NORMAL -d -p 127.0.0.1:1389:389 -p 127.0.0.1:1636:636 -h tlstest.local osixia/openldap`
|
||||
- ./test
|
||||
- docker cp ${LDAPCONTAINER}:container/service/:cfssl/assets/default-ca/default-ca.pem /tmp/openldap-ca.pem
|
||||
- docker cp ${LDAPCONTAINER}:container/service/slapd/assets/certs/ldap.key /tmp/ldap.key
|
||||
- chmod 644 /tmp/ldap.key
|
||||
- docker cp ${LDAPCONTAINER}:container/service/slapd/assets/certs/ldap.crt /tmp/ldap.crt
|
||||
- sudo sh -c 'echo "127.0.0.1 tlstest.local" >> /etc/hosts'
|
||||
- ./test-functional
|
||||
|
||||
deploy:
|
||||
|
|
|
@ -134,6 +134,83 @@ Here's an example of a `bitbucket` connector; the clientID and clientSecret shou
|
|||
}
|
||||
```
|
||||
|
||||
### `ldap` connector
|
||||
|
||||
The `ldap` connector allows email/password based authentication hosted by dex, backed by a LDAP directory.
|
||||
|
||||
Authentication is performed by binding to the configured LDAP server using the user supplied credentials. Successfull bind equals authenticated user.
|
||||
|
||||
Optionally the connector can be configured to search before authentication. The entryDN found will be used to bind to the LDAP server.
|
||||
|
||||
This feature must be enabled to get supplementary information from the directory (ID, Name, Email). This feature can also be used to limit access to the service.
|
||||
|
||||
Example use case: Allow your users to log in with e-mail address as username instead of the identification string in your DNs (typically username).
|
||||
|
||||
___NOTE:___ Users must register with dex at first login. For this to work you have to run dex-worker with --enable-registration.
|
||||
|
||||
In addition to `id` and `type`, the `ldap` connector takes the following additional fields:
|
||||
* serverHost: a `string`. The hostname for the LDAP Server.
|
||||
|
||||
* serverPort: a `unsigned 16-bit integer`. The port for the LDAP Server.
|
||||
|
||||
* timeout: `duration in milliseconds`. The timeout for connecting to and reading from LDAP Server in Milliseconds. Default: `60000` (60 Seconds)
|
||||
|
||||
* useTLS: a `boolean`. Whether the LDAP Connector should issue a StartTLS after successfully connecting to the LDAP Server.
|
||||
|
||||
* useSSL: a `boolean`. Whether the LDAP Connector should expect the connection to be encrypted, typically used with ldaps port (636/tcp).
|
||||
|
||||
* certFile: a `string`. Optional Certificate to present to LDAP server.
|
||||
|
||||
* keyFile: a `string`. Key associated with Certificate specified in `certFile`.
|
||||
|
||||
* caFile: a `string`. Filename for PEM-file containing the set of root certificate authorities that the LDAP client use when verifying the server certificates. Default: use the host's root CA set.
|
||||
|
||||
* skipCertVerification: a `boolean`. Skip server certificate chain verification.
|
||||
|
||||
* baseDN: a `string`. Base DN from which Bind DN is built and searches are based.
|
||||
|
||||
* nameAttribute: a `string`. Attribute to map to Name. Default: `cn`
|
||||
|
||||
* emailAttribute: a `string`. Attribute to map to Email. Default: `mail`
|
||||
|
||||
* searchBeforeAuth: a `boolean`. Perform search for entryDN to be used for bind.
|
||||
|
||||
* searchFilter: a `string`. Filter to apply to search. Variable substititions: `%u` User supplied username/e-mail address. `%b` BaseDN.
|
||||
|
||||
* searchScope: a `string`. Scope of the search. `base|one|sub`. Default: `one`
|
||||
|
||||
* searchBindDN: a `string`. DN to bind as for search operations.
|
||||
|
||||
* searchBindPw: a `string`. Password for bind for search operations.
|
||||
|
||||
* bindTemplate: a `string`. Template to build bindDN from user supplied credentials. Variable subtitutions: `%u` User supplied username/e-mail address. `%b` BaseDN. Default: `uid=%u,%b` ___NOTE:___ This is not used when searchBeforeAuth is enabled.
|
||||
|
||||
* trustedEmailProvider: a `boolean`. If true dex will trust the email address claims from this provider and not require that users verify their emails.
|
||||
|
||||
Here's an example of a `ldap` connector;
|
||||
|
||||
```
|
||||
{
|
||||
"type": "ldap",
|
||||
"id": "ldap",
|
||||
"serverHost": "127.0.0.1",
|
||||
"serverPort": 389,
|
||||
"useTLS": true,
|
||||
"useSSL": false,
|
||||
"skipCertVerification": false,
|
||||
"baseDN": "ou=People,dc=example,dc=com",
|
||||
"nameAttribute": "cn",
|
||||
"emailAttribute": "mail",
|
||||
"searchBeforeAuth": true,
|
||||
"searchFilter": "(mail=%u)",
|
||||
"searchScope": "one",
|
||||
"searchBindDN": "searchuser",
|
||||
"searchBindPw": "supersecret",
|
||||
"bindTemplate": "uid=%u,%b",
|
||||
"trustedEmailProvider": true
|
||||
}
|
||||
```
|
||||
|
||||
## Setting the Configuration
|
||||
|
||||
To set a connectors configuration in dex, put it in some temporary file, then use the dexctl command to upload it to dex:
|
||||
|
|
339
connector/connector_ldap.go
Normal file
339
connector/connector_ldap.go
Normal file
|
@ -0,0 +1,339 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
|
||||
"fmt"
|
||||
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/dex/pkg/log"
|
||||
"github.com/coreos/go-oidc/oidc"
|
||||
|
||||
"gopkg.in/ldap.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
LDAPConnectorType = "ldap"
|
||||
LDAPLoginPageTemplateName = "ldap-login.html"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterConnectorConfigType(LDAPConnectorType, func() ConnectorConfig { return &LDAPConnectorConfig{} })
|
||||
}
|
||||
|
||||
type LDAPConnectorConfig struct {
|
||||
ID string `json:"id"`
|
||||
ServerHost string `json:"serverHost"`
|
||||
ServerPort uint16 `json:"serverPort"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
UseTLS bool `json:"useTLS"`
|
||||
UseSSL bool `json:"useSSL"`
|
||||
CertFile string `json:"certFile"`
|
||||
KeyFile string `json:"keyFile"`
|
||||
CaFile string `json:"caFile"`
|
||||
SkipCertVerification bool `json:"skipCertVerification"`
|
||||
BaseDN string `json:"baseDN"`
|
||||
NameAttribute string `json:"nameAttribute"`
|
||||
EmailAttribute string `json:"emailAttribute"`
|
||||
SearchBeforeAuth bool `json:"searchBeforeAuth"`
|
||||
SearchFilter string `json:"searchFilter"`
|
||||
SearchScope string `json:"searchScope"`
|
||||
SearchBindDN string `json:"searchBindDN"`
|
||||
SearchBindPw string `json:"searchBindPw"`
|
||||
BindTemplate string `json:"bindTemplate"`
|
||||
TrustedEmailProvider bool `json:"trustedEmailProvider"`
|
||||
}
|
||||
|
||||
func (cfg *LDAPConnectorConfig) ConnectorID() string {
|
||||
return cfg.ID
|
||||
}
|
||||
|
||||
func (cfg *LDAPConnectorConfig) ConnectorType() string {
|
||||
return LDAPConnectorType
|
||||
}
|
||||
|
||||
type LDAPConnector struct {
|
||||
id string
|
||||
idp *LDAPIdentityProvider
|
||||
namespace url.URL
|
||||
trustedEmailProvider bool
|
||||
loginFunc oidc.LoginFunc
|
||||
loginTpl *template.Template
|
||||
}
|
||||
|
||||
func (cfg *LDAPConnectorConfig) Connector(ns url.URL, lf oidc.LoginFunc, tpls *template.Template) (Connector, error) {
|
||||
ns.Path = path.Join(ns.Path, httpPathCallback)
|
||||
tpl := tpls.Lookup(LDAPLoginPageTemplateName)
|
||||
if tpl == nil {
|
||||
return nil, fmt.Errorf("unable to find necessary HTML template")
|
||||
}
|
||||
|
||||
if cfg.UseTLS && cfg.UseSSL {
|
||||
return nil, fmt.Errorf("Invalid configuration. useTLS and useSSL are mutual exclusive.")
|
||||
}
|
||||
|
||||
if len(cfg.CertFile) > 0 && len(cfg.KeyFile) == 0 {
|
||||
return nil, fmt.Errorf("Invalid configuration. Both certFile and keyFile must be specified.")
|
||||
}
|
||||
|
||||
var nameAttribute, emailAttribute, bindTemplate string
|
||||
if len(cfg.NameAttribute) > 0 {
|
||||
nameAttribute = cfg.NameAttribute
|
||||
} else {
|
||||
nameAttribute = "cn"
|
||||
}
|
||||
|
||||
if len(cfg.EmailAttribute) > 0 {
|
||||
emailAttribute = cfg.EmailAttribute
|
||||
} else {
|
||||
emailAttribute = "mail"
|
||||
}
|
||||
|
||||
if len(cfg.BindTemplate) > 0 {
|
||||
if cfg.SearchBeforeAuth {
|
||||
log.Warningf("bindTemplate not used when searchBeforeAuth specified.")
|
||||
}
|
||||
bindTemplate = cfg.BindTemplate
|
||||
} else {
|
||||
bindTemplate = "uid=%u,%b"
|
||||
}
|
||||
|
||||
var searchScope int
|
||||
if len(cfg.SearchScope) > 0 {
|
||||
switch {
|
||||
case strings.EqualFold(cfg.SearchScope, "BASE"):
|
||||
searchScope = ldap.ScopeBaseObject
|
||||
case strings.EqualFold(cfg.SearchScope, "ONE"):
|
||||
searchScope = ldap.ScopeSingleLevel
|
||||
case strings.EqualFold(cfg.SearchScope, "SUB"):
|
||||
searchScope = ldap.ScopeWholeSubtree
|
||||
default:
|
||||
return nil, fmt.Errorf("Invalid value for searchScope: '%v'. Must be one of 'base', 'one' or 'sub'.", cfg.SearchScope)
|
||||
}
|
||||
} else {
|
||||
searchScope = ldap.ScopeSingleLevel
|
||||
}
|
||||
|
||||
if cfg.Timeout != 0 {
|
||||
ldap.DefaultTimeout = cfg.Timeout * time.Millisecond
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: cfg.ServerHost,
|
||||
InsecureSkipVerify: cfg.SkipCertVerification,
|
||||
}
|
||||
|
||||
if (cfg.UseTLS || cfg.UseSSL) && len(cfg.CaFile) > 0 {
|
||||
buf, err := ioutil.ReadFile(cfg.CaFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rootCertPool := x509.NewCertPool()
|
||||
ok := rootCertPool.AppendCertsFromPEM(buf)
|
||||
if ok {
|
||||
tlsConfig.RootCAs = rootCertPool
|
||||
} else {
|
||||
return nil, fmt.Errorf("%v: Unable to parse certificate data.", cfg.CaFile)
|
||||
}
|
||||
}
|
||||
|
||||
if (cfg.UseTLS || cfg.UseSSL) && len(cfg.CertFile) > 0 && len(cfg.KeyFile) > 0 {
|
||||
cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
idp := &LDAPIdentityProvider{
|
||||
serverHost: cfg.ServerHost,
|
||||
serverPort: cfg.ServerPort,
|
||||
useTLS: cfg.UseTLS,
|
||||
useSSL: cfg.UseSSL,
|
||||
baseDN: cfg.BaseDN,
|
||||
nameAttribute: nameAttribute,
|
||||
emailAttribute: emailAttribute,
|
||||
searchBeforeAuth: cfg.SearchBeforeAuth,
|
||||
searchFilter: cfg.SearchFilter,
|
||||
searchScope: searchScope,
|
||||
searchBindDN: cfg.SearchBindDN,
|
||||
searchBindPw: cfg.SearchBindPw,
|
||||
bindTemplate: bindTemplate,
|
||||
tlsConfig: tlsConfig,
|
||||
}
|
||||
|
||||
idpc := &LDAPConnector{
|
||||
id: cfg.ID,
|
||||
idp: idp,
|
||||
namespace: ns,
|
||||
trustedEmailProvider: cfg.TrustedEmailProvider,
|
||||
loginFunc: lf,
|
||||
loginTpl: tpl,
|
||||
}
|
||||
|
||||
return idpc, nil
|
||||
}
|
||||
|
||||
func (c *LDAPConnector) ID() string {
|
||||
return c.id
|
||||
}
|
||||
|
||||
func (c *LDAPConnector) Healthy() error {
|
||||
ldapConn, err := c.idp.LDAPConnect()
|
||||
if err == nil {
|
||||
ldapConn.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *LDAPConnector) LoginURL(sessionKey, prompt string) (string, error) {
|
||||
q := url.Values{}
|
||||
q.Set("session_key", sessionKey)
|
||||
q.Set("prompt", prompt)
|
||||
enc := q.Encode()
|
||||
|
||||
return path.Join(c.namespace.Path, "login") + "?" + enc, nil
|
||||
}
|
||||
|
||||
func (c *LDAPConnector) Register(mux *http.ServeMux, errorURL url.URL) {
|
||||
route := path.Join(c.namespace.Path, "login")
|
||||
mux.Handle(route, handleLoginFunc(c.loginFunc, c.loginTpl, c.idp, route, errorURL))
|
||||
}
|
||||
|
||||
func (c *LDAPConnector) Sync() chan struct{} {
|
||||
return make(chan struct{})
|
||||
}
|
||||
|
||||
func (c *LDAPConnector) TrustedEmailProvider() bool {
|
||||
return c.trustedEmailProvider
|
||||
}
|
||||
|
||||
type LDAPIdentityProvider struct {
|
||||
serverHost string
|
||||
serverPort uint16
|
||||
useTLS bool
|
||||
useSSL bool
|
||||
baseDN string
|
||||
nameAttribute string
|
||||
emailAttribute string
|
||||
searchBeforeAuth bool
|
||||
searchFilter string
|
||||
searchScope int
|
||||
searchBindDN string
|
||||
searchBindPw string
|
||||
bindTemplate string
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func (m *LDAPIdentityProvider) LDAPConnect() (*ldap.Conn, error) {
|
||||
var err error
|
||||
var ldapConn *ldap.Conn
|
||||
|
||||
log.Debugf("LDAPConnect()")
|
||||
if m.useSSL {
|
||||
ldapConn, err = ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", m.serverHost, m.serverPort), m.tlsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
ldapConn, err = ldap.Dial("tcp", fmt.Sprintf("%s:%d", m.serverHost, m.serverPort))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.useTLS {
|
||||
err = ldapConn.StartTLS(m.tlsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ldapConn, err
|
||||
}
|
||||
|
||||
func (m *LDAPIdentityProvider) ParseString(template, username string) string {
|
||||
result := template
|
||||
result = strings.Replace(result, "%u", username, -1)
|
||||
result = strings.Replace(result, "%b", m.baseDN, -1)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *LDAPIdentityProvider) Identity(username, password string) (*oidc.Identity, error) {
|
||||
var err error
|
||||
var bindDN, ldapUid, ldapName, ldapEmail string
|
||||
var ldapConn *ldap.Conn
|
||||
|
||||
ldapConn, err = m.LDAPConnect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer ldapConn.Close()
|
||||
|
||||
if m.searchBeforeAuth {
|
||||
err = ldapConn.Bind(m.searchBindDN, m.searchBindPw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filter := m.ParseString(m.searchFilter, username)
|
||||
|
||||
attributes := []string{
|
||||
"entryDN",
|
||||
m.nameAttribute,
|
||||
m.emailAttribute,
|
||||
}
|
||||
|
||||
s := ldap.NewSearchRequest(m.baseDN, m.searchScope, ldap.NeverDerefAliases, 0, 0, false, filter, attributes, nil)
|
||||
|
||||
sr, err := ldapConn.Search(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sr.Entries) == 0 {
|
||||
err = fmt.Errorf("Search returned no match. filter='%v' base='%v'", filter, m.baseDN)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bindDN = sr.Entries[0].GetAttributeValue("entryDN")
|
||||
ldapName = sr.Entries[0].GetAttributeValue(m.nameAttribute)
|
||||
ldapEmail = sr.Entries[0].GetAttributeValue(m.emailAttribute)
|
||||
|
||||
// drop to anonymous bind, prepare for bind as user
|
||||
err = ldapConn.Bind("", "")
|
||||
if err != nil {
|
||||
// unsupported or disallowed, reconnect
|
||||
log.Warningf("Re-connecting to LDAP Server after failure to bind anonymously: %v", err)
|
||||
ldapConn.Close()
|
||||
ldapConn, err = m.LDAPConnect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bindDN = m.ParseString(m.bindTemplate, username)
|
||||
}
|
||||
|
||||
// authenticate user
|
||||
err = ldapConn.Bind(bindDN, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ldapUid = bindDN
|
||||
|
||||
return &oidc.Identity{
|
||||
ID: ldapUid,
|
||||
Name: ldapName,
|
||||
Email: ldapEmail,
|
||||
}, nil
|
||||
}
|
94
connector/connector_ldap_test.go
Normal file
94
connector/connector_ldap_test.go
Normal file
|
@ -0,0 +1,94 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/coreos/go-oidc/oidc"
|
||||
)
|
||||
|
||||
var (
|
||||
ns url.URL
|
||||
lf oidc.LoginFunc
|
||||
templates *template.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
templates = template.New(LDAPLoginPageTemplateName)
|
||||
}
|
||||
|
||||
func TestLDAPConnectorConfigValidTLS(t *testing.T) {
|
||||
cc := LDAPConnectorConfig{
|
||||
ID: "ldap",
|
||||
UseTLS: true,
|
||||
UseSSL: false,
|
||||
}
|
||||
|
||||
_, err := cc.Connector(ns, lf, templates)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLDAPConnectorConfigInvalidSSLandTLS(t *testing.T) {
|
||||
cc := LDAPConnectorConfig{
|
||||
ID: "ldap",
|
||||
UseTLS: true,
|
||||
UseSSL: true,
|
||||
}
|
||||
|
||||
_, err := cc.Connector(ns, lf, templates)
|
||||
if err == nil {
|
||||
t.Fatal("Expected LDAPConnector initialization to fail when both TLS and SSL enabled.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLDAPConnectorConfigValidSearchScope(t *testing.T) {
|
||||
cc := LDAPConnectorConfig{
|
||||
ID: "ldap",
|
||||
SearchScope: "one",
|
||||
}
|
||||
|
||||
_, err := cc.Connector(ns, lf, templates)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLDAPConnectorConfigInvalidSearchScope(t *testing.T) {
|
||||
cc := LDAPConnectorConfig{
|
||||
ID: "ldap",
|
||||
SearchScope: "three",
|
||||
}
|
||||
|
||||
_, err := cc.Connector(ns, lf, templates)
|
||||
if err == nil {
|
||||
t.Fatal("Expected LDAPConnector initialization to fail when invalid value provided for SearchScope.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLDAPConnectorConfigInvalidCertFileNoKeyFile(t *testing.T) {
|
||||
cc := LDAPConnectorConfig{
|
||||
ID: "ldap",
|
||||
CertFile: "/tmp/ldap.crt",
|
||||
}
|
||||
|
||||
_, err := cc.Connector(ns, lf, templates)
|
||||
if err == nil {
|
||||
t.Fatal("Expected LDAPConnector initialization to fail when CertFile specified without KeyFile.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLDAPConnectorConfigValidCertFileAndKeyFile(t *testing.T) {
|
||||
cc := LDAPConnectorConfig{
|
||||
ID: "ldap",
|
||||
CertFile: "/tmp/ldap.crt",
|
||||
KeyFile: "/tmp/ldap.key",
|
||||
}
|
||||
|
||||
_, err := cc.Connector(ns, lf, templates)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
|
@ -7,10 +7,7 @@ import (
|
|||
"net/url"
|
||||
"path"
|
||||
|
||||
phttp "github.com/coreos/dex/pkg/http"
|
||||
"github.com/coreos/dex/pkg/log"
|
||||
"github.com/coreos/dex/user"
|
||||
"github.com/coreos/go-oidc/oauth2"
|
||||
"github.com/coreos/go-oidc/oidc"
|
||||
)
|
||||
|
||||
|
@ -102,90 +99,6 @@ func (c *LocalConnector) TrustedEmailProvider() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func redirectPostError(w http.ResponseWriter, errorURL url.URL, q url.Values) {
|
||||
redirectURL := phttp.MergeQuery(errorURL, q)
|
||||
w.Header().Set("Location", redirectURL.String())
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleLoginFunc(lf oidc.LoginFunc, tpl *template.Template, idp *LocalIdentityProvider, localErrorPath string, errorURL url.URL) http.HandlerFunc {
|
||||
handleGET := func(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||
q := r.URL.Query()
|
||||
sessionKey := q.Get("session_key")
|
||||
|
||||
p := &Page{PostURL: r.URL.String(), Name: "Local", SessionKey: sessionKey}
|
||||
if errMsg != "" {
|
||||
p.Error = true
|
||||
p.Message = errMsg
|
||||
}
|
||||
|
||||
if err := tpl.Execute(w, p); err != nil {
|
||||
phttp.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
handlePOST := func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
msg := fmt.Sprintf("unable to parse form from body: %v", err)
|
||||
phttp.WriteError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
userid := r.PostForm.Get("userid")
|
||||
if userid == "" {
|
||||
handleGET(w, r, "missing email address")
|
||||
return
|
||||
}
|
||||
|
||||
password := r.PostForm.Get("password")
|
||||
if password == "" {
|
||||
handleGET(w, r, "missing password")
|
||||
return
|
||||
}
|
||||
|
||||
ident, err := idp.Identity(userid, password)
|
||||
log.Errorf("IDENTITY: err: %v", err)
|
||||
|
||||
if ident == nil || err != nil {
|
||||
handleGET(w, r, "invalid login")
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
sessionKey := r.FormValue("session_key")
|
||||
if sessionKey == "" {
|
||||
q.Set("error", oauth2.ErrorInvalidRequest)
|
||||
q.Set("error_description", "missing session_key")
|
||||
redirectPostError(w, errorURL, q)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL, err := lf(*ident, sessionKey)
|
||||
if err != nil {
|
||||
log.Errorf("Unable to log in %#v: %v", *ident, err)
|
||||
q.Set("error", oauth2.ErrorAccessDenied)
|
||||
q.Set("error_description", "login failed")
|
||||
redirectPostError(w, errorURL, q)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Location", redirectURL)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
handlePOST(w, r)
|
||||
case "GET":
|
||||
handleGET(w, r, "")
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
phttp.WriteError(w, http.StatusMethodNotAllowed, "GET and POST only acceptable methods")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type LocalIdentityProvider struct {
|
||||
PasswordInfoRepo user.PasswordInfoRepo
|
||||
UserRepo user.UserRepo
|
||||
|
|
|
@ -64,3 +64,7 @@ type ConnectorConfigRepo interface {
|
|||
All() ([]ConnectorConfig, error)
|
||||
GetConnectorByID(repo.Transaction, string) (ConnectorConfig, error)
|
||||
}
|
||||
|
||||
type IdentityProvider interface {
|
||||
Identity(email, password string) (*oidc.Identity, error)
|
||||
}
|
||||
|
|
97
connector/login_local.go
Normal file
97
connector/login_local.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
phttp "github.com/coreos/dex/pkg/http"
|
||||
"github.com/coreos/dex/pkg/log"
|
||||
"github.com/coreos/go-oidc/oauth2"
|
||||
"github.com/coreos/go-oidc/oidc"
|
||||
)
|
||||
|
||||
func redirectPostError(w http.ResponseWriter, errorURL url.URL, q url.Values) {
|
||||
redirectURL := phttp.MergeQuery(errorURL, q)
|
||||
w.Header().Set("Location", redirectURL.String())
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleLoginFunc(lf oidc.LoginFunc, tpl *template.Template, idp IdentityProvider, localErrorPath string, errorURL url.URL) http.HandlerFunc {
|
||||
handleGET := func(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||
q := r.URL.Query()
|
||||
sessionKey := q.Get("session_key")
|
||||
|
||||
p := &Page{PostURL: r.URL.String(), Name: "Local", SessionKey: sessionKey}
|
||||
if errMsg != "" {
|
||||
p.Error = true
|
||||
p.Message = errMsg
|
||||
}
|
||||
|
||||
if err := tpl.Execute(w, p); err != nil {
|
||||
phttp.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
handlePOST := func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
msg := fmt.Sprintf("unable to parse form from body: %v", err)
|
||||
phttp.WriteError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
userid := r.PostForm.Get("userid")
|
||||
if userid == "" {
|
||||
handleGET(w, r, "missing email address")
|
||||
return
|
||||
}
|
||||
|
||||
password := r.PostForm.Get("password")
|
||||
if password == "" {
|
||||
handleGET(w, r, "missing password")
|
||||
return
|
||||
}
|
||||
|
||||
ident, err := idp.Identity(userid, password)
|
||||
log.Errorf("IDENTITY: err: %v", err)
|
||||
|
||||
if ident == nil || err != nil {
|
||||
handleGET(w, r, "invalid login")
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
sessionKey := r.FormValue("session_key")
|
||||
if sessionKey == "" {
|
||||
q.Set("error", oauth2.ErrorInvalidRequest)
|
||||
q.Set("error_description", "missing session_key")
|
||||
redirectPostError(w, errorURL, q)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL, err := lf(*ident, sessionKey)
|
||||
if err != nil {
|
||||
log.Errorf("Unable to log in %#v: %v", *ident, err)
|
||||
q.Set("error", oauth2.ErrorAccessDenied)
|
||||
q.Set("error_description", "login failed")
|
||||
redirectPostError(w, errorURL, q)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Location", redirectURL)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
handlePOST(w, r)
|
||||
case "GET":
|
||||
handleGET(w, r, "")
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
phttp.WriteError(w, http.StatusMethodNotAllowed, "GET and POST only acceptable methods")
|
||||
}
|
||||
}
|
||||
}
|
207
functional/ldap_test.go
Normal file
207
functional/ldap_test.go
Normal file
|
@ -0,0 +1,207 @@
|
|||
package functional
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/coreos/dex/connector"
|
||||
"github.com/coreos/dex/repo"
|
||||
"github.com/coreos/go-oidc/oidc"
|
||||
"gopkg.in/ldap.v2"
|
||||
)
|
||||
|
||||
var (
|
||||
ldapHost string
|
||||
ldapPort uint16
|
||||
ldapBindDN string
|
||||
ldapBindPw string
|
||||
)
|
||||
|
||||
func init() {
|
||||
ldapuri := os.Getenv("DEX_TEST_LDAP_URI")
|
||||
if ldapuri == "" {
|
||||
fmt.Println("Unable to proceed with empty env var " +
|
||||
"DEX_TEST_LDAP_URI")
|
||||
os.Exit(1)
|
||||
}
|
||||
u, err := url.Parse(ldapuri)
|
||||
if err != nil {
|
||||
fmt.Println("Unable to parse DEX_TEST_LDAP_URI")
|
||||
os.Exit(1)
|
||||
}
|
||||
if strings.Index(u.RawQuery, "?") < 0 {
|
||||
fmt.Println("Unable to parse DEX_TEST_LDAP_URI")
|
||||
os.Exit(1)
|
||||
}
|
||||
extentions := make(map[string]string)
|
||||
kvs := strings.Split(strings.TrimLeft(u.RawQuery, "?"), ",")
|
||||
for i := range kvs {
|
||||
fmt.Println(kvs[i])
|
||||
kv := strings.Split(kvs[i], "=")
|
||||
if len(kv) < 2 {
|
||||
fmt.Println("Unable to parse DEX_TEST_LDAP_URI")
|
||||
os.Exit(1)
|
||||
}
|
||||
extentions[kv[0]] = kv[1]
|
||||
}
|
||||
hostport := strings.Split(u.Host, ":")
|
||||
port := 389
|
||||
if len(hostport) > 1 {
|
||||
port, _ = strconv.Atoi(hostport[1])
|
||||
}
|
||||
|
||||
ldapHost = hostport[0]
|
||||
ldapPort = uint16(port)
|
||||
|
||||
if len(extentions["bindname"]) > 0 {
|
||||
ldapBindDN, err = url.QueryUnescape(extentions["bindname"])
|
||||
if err != nil {
|
||||
fmt.Println("Unable to parse DEX_TEST_LDAP_URI")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if len(extentions["X-BINDPW"]) > 0 {
|
||||
ldapBindPw = extentions["X-BINDPW"]
|
||||
}
|
||||
}
|
||||
|
||||
func TestLDAPConnect(t *testing.T) {
|
||||
fmt.Println("ldapHost: ", ldapHost)
|
||||
fmt.Println("ldapPort: ", ldapPort)
|
||||
fmt.Println("ldapBindDN: ", ldapBindDN)
|
||||
fmt.Println("ldapBindPw: ", ldapBindPw)
|
||||
l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", ldapHost, ldapPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = l.Bind(ldapBindDN, ldapBindPw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.Close()
|
||||
}
|
||||
|
||||
func TestConnectorLDAPConnectFail(t *testing.T) {
|
||||
var tx repo.Transaction
|
||||
var lf oidc.LoginFunc
|
||||
var ns url.URL
|
||||
|
||||
templates := template.New(connector.LDAPLoginPageTemplateName)
|
||||
|
||||
ccr := connector.NewConnectorConfigRepoFromConfigs(
|
||||
[]connector.ConnectorConfig{&connector.LDAPConnectorConfig{
|
||||
ID: "ldap",
|
||||
ServerHost: ldapHost,
|
||||
ServerPort: ldapPort + 1,
|
||||
}},
|
||||
)
|
||||
cc, err := ccr.GetConnectorByID(tx, "ldap")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := cc.Connector(ns, lf, templates)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = c.Healthy()
|
||||
if err == nil {
|
||||
t.Fatal(fmt.Errorf("LDAPConnector.Healty() supposed to fail, but succeeded!"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorLDAPConnectSuccess(t *testing.T) {
|
||||
var tx repo.Transaction
|
||||
var lf oidc.LoginFunc
|
||||
var ns url.URL
|
||||
|
||||
templates := template.New(connector.LDAPLoginPageTemplateName)
|
||||
|
||||
ccr := connector.NewConnectorConfigRepoFromConfigs(
|
||||
[]connector.ConnectorConfig{&connector.LDAPConnectorConfig{
|
||||
ID: "ldap",
|
||||
ServerHost: ldapHost,
|
||||
ServerPort: ldapPort,
|
||||
}},
|
||||
)
|
||||
cc, err := ccr.GetConnectorByID(tx, "ldap")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := cc.Connector(ns, lf, templates)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = c.Healthy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorLDAPcaFilecertFileConnectTLS(t *testing.T) {
|
||||
var tx repo.Transaction
|
||||
var lf oidc.LoginFunc
|
||||
var ns url.URL
|
||||
|
||||
templates := template.New(connector.LDAPLoginPageTemplateName)
|
||||
|
||||
ccr := connector.NewConnectorConfigRepoFromConfigs(
|
||||
[]connector.ConnectorConfig{&connector.LDAPConnectorConfig{
|
||||
ID: "ldap",
|
||||
ServerHost: ldapHost,
|
||||
ServerPort: ldapPort,
|
||||
UseTLS: true,
|
||||
CertFile: "/tmp/ldap.crt",
|
||||
KeyFile: "/tmp/ldap.key",
|
||||
CaFile: "/tmp/openldap-ca.pem",
|
||||
}},
|
||||
)
|
||||
cc, err := ccr.GetConnectorByID(tx, "ldap")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := cc.Connector(ns, lf, templates)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = c.Healthy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorLDAPcaFilecertFileConnectSSL(t *testing.T) {
|
||||
var tx repo.Transaction
|
||||
var lf oidc.LoginFunc
|
||||
var ns url.URL
|
||||
|
||||
templates := template.New(connector.LDAPLoginPageTemplateName)
|
||||
|
||||
ccr := connector.NewConnectorConfigRepoFromConfigs(
|
||||
[]connector.ConnectorConfig{&connector.LDAPConnectorConfig{
|
||||
ID: "ldap",
|
||||
ServerHost: ldapHost,
|
||||
ServerPort: ldapPort + 247, // 636
|
||||
UseSSL: true,
|
||||
CertFile: "/tmp/ldap.crt",
|
||||
KeyFile: "/tmp/ldap.key",
|
||||
CaFile: "/tmp/openldap-ca.pem",
|
||||
}},
|
||||
)
|
||||
cc, err := ccr.GetConnectorByID(tx, "ldap")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := cc.Connector(ns, lf, templates)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = c.Healthy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
|
@ -31,5 +31,24 @@
|
|||
"id": "bitbucket",
|
||||
"clientID": "${CLIENT_ID}",
|
||||
"clientSecret": "${CLIENT_SECRET}"
|
||||
},
|
||||
{
|
||||
"type": "ldap",
|
||||
"id": "ldap",
|
||||
"serverHost": "127.0.0.1",
|
||||
"serverPort": 389,
|
||||
"useTLS": true,
|
||||
"useSSL": false,
|
||||
"caFile": "/etc/ssl/certs/example_com_root.crt",
|
||||
"skipCertVerification": false,
|
||||
"baseDN": "ou=People,dc=example,dc=com",
|
||||
"nameAttribute": "cn",
|
||||
"emailAttribute": "mail",
|
||||
"searchBeforeAuth": true,
|
||||
"searchFilter": "(mail=%u)",
|
||||
"searchScope": "one",
|
||||
"searchBindDN": "searchuser",
|
||||
"searchBindPw": "supersecret",
|
||||
"bindTemplate": "uid=%u,%b"
|
||||
}
|
||||
]
|
||||
|
|
30
static/html/ldap-login.html
Normal file
30
static/html/ldap-login.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
{{ template "header.html" }}
|
||||
|
||||
<div class="panel">
|
||||
<h2 class="heading">Log in to Your Account</h2>
|
||||
<form method="post" action="{{.PostURL}}">
|
||||
<div class="form-row">
|
||||
LDAP
|
||||
<div class="input-desc">
|
||||
<label for="userid">Username</label>
|
||||
</div>
|
||||
<input tabindex="1" required id="userid" name="userid" type="text" class="input-box" placeholder="username" autofocus/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="input-desc">
|
||||
<label for="password">Password</label>
|
||||
<span class="subtle-text input-label-right">Forgot? <a href="/send-reset-password?session_key={{ .SessionKey }}">Reset Password</a></span>
|
||||
</div>
|
||||
<input tabindex="2" required id="password" name="password" type="password" class="input-box" placeholder="password"/>
|
||||
</div>
|
||||
|
||||
{{ if .Error }}
|
||||
<div class="error-box">{{ .Message }}</div>
|
||||
{{ end }}
|
||||
|
||||
<button tabindex="3" type="submit" class="btn btn-primary">Login</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{ template "footer.html" }}
|
Reference in a new issue