diff --git a/.travis.yml b/.travis.yml index b916805a..76c716d7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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: diff --git a/Documentation/connectors-configuration.md b/Documentation/connectors-configuration.md index 72f96950..3637f0a6 100644 --- a/Documentation/connectors-configuration.md +++ b/Documentation/connectors-configuration.md @@ -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: diff --git a/connector/connector_ldap.go b/connector/connector_ldap.go new file mode 100644 index 00000000..2b7747c2 --- /dev/null +++ b/connector/connector_ldap.go @@ -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 +} diff --git a/connector/connector_ldap_test.go b/connector/connector_ldap_test.go new file mode 100644 index 00000000..f5e2c9e8 --- /dev/null +++ b/connector/connector_ldap_test.go @@ -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) + } +} diff --git a/connector/connector_local.go b/connector/connector_local.go index 63a949ce..00fd0402 100644 --- a/connector/connector_local.go +++ b/connector/connector_local.go @@ -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 diff --git a/connector/interface.go b/connector/interface.go index b7ca0139..e0348142 100644 --- a/connector/interface.go +++ b/connector/interface.go @@ -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) +} diff --git a/connector/login_local.go b/connector/login_local.go new file mode 100644 index 00000000..ddb2423a --- /dev/null +++ b/connector/login_local.go @@ -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") + } + } +} diff --git a/functional/ldap_test.go b/functional/ldap_test.go new file mode 100644 index 00000000..e4a46987 --- /dev/null +++ b/functional/ldap_test.go @@ -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) + } +} diff --git a/static/fixtures/connectors.json.sample b/static/fixtures/connectors.json.sample index a3cbe39f..681ed3e9 100644 --- a/static/fixtures/connectors.json.sample +++ b/static/fixtures/connectors.json.sample @@ -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" } ] diff --git a/static/html/ldap-login.html b/static/html/ldap-login.html new file mode 100644 index 00000000..4ea4cd65 --- /dev/null +++ b/static/html/ldap-login.html @@ -0,0 +1,30 @@ +{{ template "header.html" }} + +
+

Log in to Your Account

+
+
+ LDAP +
+ +
+ +
+
+
+ + Forgot? Reset Password +
+ +
+ + {{ if .Error }} +
{{ .Message }}
+ {{ end }} + + + +
+
+ +{{ template "footer.html" }}