From 13f7dfaef04f3bbc80cc9f7f2d57d7ecb4ef8b7c Mon Sep 17 00:00:00 2001 From: Eric Chiang Date: Thu, 20 Oct 2016 17:17:43 -0700 Subject: [PATCH] connector/ldap: expand LDAP connector to include searches --- connector/ldap/ldap.go | 393 ++++++++++++++++++++++++++++++++++-- connector/ldap/ldap_test.go | 23 +++ 2 files changed, 401 insertions(+), 15 deletions(-) create mode 100644 connector/ldap/ldap_test.go diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go index 867f86d6..21c35ef4 100644 --- a/connector/ldap/ldap.go +++ b/connector/ldap/ldap.go @@ -2,58 +2,421 @@ package ldap import ( - "errors" + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" "fmt" + "io/ioutil" + "log" + "net" + "strings" + "unicode" "gopkg.in/ldap.v2" "github.com/coreos/dex/connector" ) -// Config holds the configuration parameters for the LDAP connector. +// Config holds the configuration parameters for the LDAP connector. The LDAP +// connectors require executing two queries, the first to find the user based on +// the username and password given to the connector. The second to use the user +// entry to search for groups. +// +// An example config: +// +// type: ldap +// config: +// host: ldap.example.com:636 +// # The following field is required if using port 389. +// # insecureNoSSL: true +// rootCA: /etc/dex/ldap.ca +// bindDN: uid=seviceaccount,cn=users,dc=example,dc=com +// bindPW: password +// userSearch: +// # Would translate to the query "(&(objectClass=person)(uid=))" +// baseDN: cn=users,dc=example,dc=com +// filter: "(objectClass=person)" +// username: uid +// idAttr: uid +// emailAttr: mail +// nameAttr: name +// groupSearch: +// # Would translate to the query "(&(objectClass=group)(member=))" +// baseDN: cn=groups,dc=example,dc=com +// filter: "(objectClass=group)" +// userAttr: uid +// groupAttr: member +// nameAttr: name +// type Config struct { - Host string `yaml:"host"` + // The host and optional port of the LDAP server. If port isn't supplied, it will be + // guessed based on the TLS configuration. 389 or 636. + Host string `yaml:"host"` + + // Required if LDAP host does not use TLS. + InsecureNoSSL bool `yaml:"insecureNoSSL"` + + // Path to a trusted root certificate file. + RootCA string `yaml:"rootCA"` + + // BindDN and BindPW for an application service account. The connector uses these + // credentials to search for users and groups. BindDN string `yaml:"bindDN"` + BindPW string `yaml:"bindPW"` + + // User entry search configuration. + UserSearch struct { + // BsaeDN to start the search from. For example "cn=users,dc=example,dc=com" + BaseDN string `yaml:"baseDN"` + + // Optional filter to apply when searching the directory. For example "(objectClass=person)" + Filter string `yaml:"filter"` + + // Attribute to match against the inputted username. This will be translated and combined + // with the other filter as "(=)". + Username string `yaml:"username"` + + // Can either be: + // * "sub" - search the whole sub tree + // * "one" - only search one level + Scope string `yaml:"scope"` + + // A mapping of attributes on the user entry to claims. + IDAttr string `yaml:"idAttr"` // Defaults to "uid" + EmailAttr string `yaml:"emailAttr"` // Defaults to "mail" + NameAttr string `yaml:"nameAttr"` // No default. + + } `yaml:"userSearch"` + + // Group search configuration. + GroupSearch struct { + // BsaeDN to start the search from. For example "cn=groups,dc=example,dc=com" + BaseDN string `yaml:"baseDN"` + + // Optional filter to apply when searching the directory. For example "(objectClass=posixGroup)" + Filter string `yaml:"filter"` + + Scope string `yaml:"scope"` // Defaults to "sub" + + // These two fields are use to match a user to a group. + // + // It adds an additional requirement to the filter that an attribute in the group + // match the user's attribute value. For example that the "members" attribute of + // a group matches the "uid" of the user. The exact filter being added is: + // + // (=) + // + UserAttr string `yaml:"userAttr"` + GroupAttr string `yaml:"groupAttr"` + + // The attribute of the group that represents its name. + NameAttr string `yaml:"nameAttr"` + } `yaml:"groupSearch"` +} + +func parseScope(s string) (int, bool) { + // NOTE(ericchiang): ScopeBaseObject doesn't really make sense for us because we + // never know the user's or group's DN. + switch s { + case "", "sub": + return ldap.ScopeWholeSubtree, true + case "one": + return ldap.ScopeSingleLevel, true + } + return 0, false +} + +// escapeRune maps a rune to a hex encoded value. For example 'é' would become '\\c3\\a9' +func escapeRune(buff *bytes.Buffer, r rune) { + // Really inefficient, but it seems correct. + for _, b := range []byte(string(r)) { + buff.WriteString("\\") + buff.WriteString(hex.EncodeToString([]byte{b})) + } +} + +// NOTE(ericchiang): There are no good documents on how to escape an LDAP string. +// This implementation is inspired by an Oracle document, and is purposefully +// extremely restrictive. +// +// See: https://docs.oracle.com/cd/E19424-01/820-4811/gdxpo/index.html +func escapeFilter(s string) string { + r := strings.NewReader(s) + buff := new(bytes.Buffer) + for { + ru, _, err := r.ReadRune() + if err != nil { + // ignore decoding issues + return buff.String() + } + + switch { + case ru > unicode.MaxASCII: // Not ASCII + escapeRune(buff, ru) + case !unicode.IsPrint(ru): // Not printable + escapeRune(buff, ru) + case strings.ContainsRune(`*\()`, ru): // Reserved characters + escapeRune(buff, ru) + default: + buff.WriteRune(ru) + } + } } // Open returns an authentication strategy using LDAP. func (c *Config) Open() (connector.Connector, error) { - if c.Host == "" { - return nil, errors.New("missing host parameter") + requiredFields := []struct { + name string + val string + }{ + {"host", c.Host}, + {"userSearch.baseDN", c.UserSearch.BaseDN}, + {"userSearch.username", c.UserSearch.Username}, } - if c.BindDN == "" { - return nil, errors.New("missing bindDN paramater") + + for _, field := range requiredFields { + if field.val == "" { + return nil, fmt.Errorf("ldap: missing required field %q", field.name) + } } - return &ldapConnector{*c}, nil + + var ( + host string + err error + ) + if host, _, err = net.SplitHostPort(c.Host); err != nil { + host = c.Host + if c.InsecureNoSSL { + c.Host = c.Host + ":389" + } else { + c.Host = c.Host + ":636" + } + } + + tlsConfig := new(tls.Config) + if c.RootCA != "" { + data, err := ioutil.ReadFile(c.RootCA) + if err != nil { + return nil, fmt.Errorf("ldap: read ca file: %v", err) + } + rootCAs := x509.NewCertPool() + if !rootCAs.AppendCertsFromPEM(data) { + return nil, fmt.Errorf("ldap: no certs found in ca file") + } + tlsConfig.RootCAs = rootCAs + // NOTE(ericchiang): This was required for our internal LDAP server + // but might be because of an issue with our root CA. + tlsConfig.ServerName = host + } + userSearchScope, ok := parseScope(c.UserSearch.Scope) + if !ok { + return nil, fmt.Errorf("userSearch.Scope unknown value %q", c.UserSearch.Scope) + } + groupSearchScope, ok := parseScope(c.GroupSearch.Scope) + if !ok { + return nil, fmt.Errorf("userSearch.Scope unknown value %q", c.GroupSearch.Scope) + } + return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig}, nil } type ldapConnector struct { Config + + userSearchScope int + groupSearchScope int + + tlsConfig *tls.Config } var _ connector.PasswordConnector = (*ldapConnector)(nil) +// do initializes a connection to the LDAP directory and passes it to the +// provided function. It then performs appropriate teardown or reuse before +// returning. func (c *ldapConnector) do(f func(c *ldap.Conn) error) error { - // TODO(ericchiang): Connection pooling. - conn, err := ldap.Dial("tcp", c.Host) + var ( + conn *ldap.Conn + err error + ) + if c.InsecureNoSSL { + conn, err = ldap.Dial("tcp", c.Host) + } else { + conn, err = ldap.DialTLS("tcp", c.Host, c.tlsConfig) + } if err != nil { return fmt.Errorf("failed to connect: %v", err) } defer conn.Close() + // If bindDN and bindPW are empty this will default to an anonymous bind. + if err := conn.Bind(c.BindDN, c.BindPW); err != nil { + return fmt.Errorf("ldap: initial bind for user %q failed: %v", c.BindDN, err) + } + return f(conn) } -func (c *ldapConnector) Login(username, password string) (connector.Identity, bool, error) { - err := c.do(func(conn *ldap.Conn) error { - return conn.Bind(fmt.Sprintf("uid=%s,%s", username, c.BindDN), password) +func getAttr(e ldap.Entry, name string) string { + for _, a := range e.Attributes { + if a.Name != name { + continue + } + if len(a.Values) == 0 { + return "" + } + return a.Values[0] + } + return "" +} + +func (c *ldapConnector) Login(username, password string) (ident connector.Identity, validPass bool, err error) { + var ( + // We want to return a different error if the user's password is incorrect vs + // if there was an error. + incorrectPass = false + user ldap.Entry + ) + + filter := fmt.Sprintf("(%s=%s)", c.UserSearch.Username, escapeFilter(username)) + if c.UserSearch.Filter != "" { + filter = fmt.Sprintf("(&%s%s)", c.UserSearch.Filter, filter) + } + + // Initial search. + req := &ldap.SearchRequest{ + BaseDN: c.UserSearch.BaseDN, + Filter: filter, + Scope: c.userSearchScope, + // We only need to search for these specific requests. + Attributes: []string{ + c.UserSearch.IDAttr, + c.UserSearch.EmailAttr, + c.GroupSearch.UserAttr, + // TODO(ericchiang): what if this contains duplicate values? + }, + } + + if c.UserSearch.NameAttr != "" { + req.Attributes = append(req.Attributes, c.UserSearch.NameAttr) + } + + err = c.do(func(conn *ldap.Conn) error { + resp, err := conn.Search(req) + if err != nil { + return fmt.Errorf("ldap: search with filter %q failed: %v", req.Filter, err) + } + + switch n := len(resp.Entries); n { + case 0: + return fmt.Errorf("ldap: no results returned for filter: %q", filter) + case 2: + return fmt.Errorf("ldap: filter returned multiple (%d) results: %q", n, filter) + } + + user = *resp.Entries[0] + + // Try to authenticate as the distinguished name. + if err := conn.Bind(user.DN, password); err != nil { + // Detect a bad password through the LDAP error code. + if ldapErr, ok := err.(*ldap.Error); ok { + if ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials { + log.Printf("ldap: invalid password for user %q", user.DN) + incorrectPass = true + return nil + } + } + return fmt.Errorf("ldap: failed to bind as dn %q: %v", user.DN, err) + } + return nil }) if err != nil { - // TODO(ericchiang): Determine when the user has entered invalid credentials. return connector.Identity{}, false, err } - return connector.Identity{Username: username}, true, nil + // Encode entry for follow up requests such as the groups query and + // refresh attempts. + if ident.ConnectorData, err = json.Marshal(user); err != nil { + return connector.Identity{}, false, fmt.Errorf("ldap: marshal entry: %v", err) + } + + // If we're missing any attributes, such as email or ID, we want to report + // an error rather than continuing. + missing := []string{} + + // Fill the identity struct using the attributes from the user entry. + if ident.UserID = getAttr(user, c.UserSearch.IDAttr); ident.UserID == "" { + missing = append(missing, c.UserSearch.IDAttr) + } + if ident.Email = getAttr(user, c.UserSearch.EmailAttr); ident.Email == "" { + missing = append(missing, c.UserSearch.EmailAttr) + } + if c.UserSearch.NameAttr != "" { + if ident.Username = getAttr(user, c.UserSearch.NameAttr); ident.Username == "" { + missing = append(missing, c.UserSearch.NameAttr) + } + } + + if len(missing) != 0 { + err := fmt.Errorf("ldap: entry %q missing following required attribute(s): %q", user.DN, missing) + return connector.Identity{}, false, err + } + + return ident, !incorrectPass, nil +} + +func (c *ldapConnector) Groups(ident connector.Identity) ([]string, error) { + // Decode the user entry from the identity. + var user ldap.Entry + if err := json.Unmarshal(ident.ConnectorData, &user); err != nil { + return nil, fmt.Errorf("ldap: failed to unmarshal connector data: %v", err) + } + + filter := fmt.Sprintf("(%s=%s)", c.GroupSearch.GroupAttr, escapeFilter(getAttr(user, c.GroupSearch.UserAttr))) + if c.GroupSearch.Filter != "" { + filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter) + } + + req := &ldap.SearchRequest{ + BaseDN: c.GroupSearch.BaseDN, + Filter: filter, + Scope: c.groupSearchScope, + Attributes: []string{c.GroupSearch.NameAttr}, + } + + var groups []*ldap.Entry + if err := c.do(func(conn *ldap.Conn) error { + resp, err := conn.Search(req) + if err != nil { + return fmt.Errorf("ldap: search failed: %v", err) + } + groups = resp.Entries + return nil + }); err != nil { + return nil, err + } + if len(groups) == 0 { + // TODO(ericchiang): Is this going to spam the logs? + log.Printf("ldap: groups search with filter %q returned no groups", filter) + } + + var groupNames []string + + for _, group := range groups { + name := getAttr(*group, c.GroupSearch.NameAttr) + if name == "" { + // Be obnoxious about missing missing attributes. If the group entry is + // missing its name attribute, that indicates a misconfiguration. + // + // In the future we can add configuration options to just log these errors. + return nil, fmt.Errorf("ldap: group entity %q missing required attribute %q", + group.DN, c.GroupSearch.NameAttr) + } + + groupNames = append(groupNames, name) + } + return groupNames, nil } func (c *ldapConnector) Close() error { diff --git a/connector/ldap/ldap_test.go b/connector/ldap/ldap_test.go new file mode 100644 index 00000000..2a93e316 --- /dev/null +++ b/connector/ldap/ldap_test.go @@ -0,0 +1,23 @@ +package ldap + +import "testing" + +func TestEscapeFilter(t *testing.T) { + tests := []struct { + val string + want string + }{ + {"Five*Star", "Five\\2aStar"}, + {"c:\\File", "c:\\5cFile"}, + {"John (2nd)", "John \\282nd\\29"}, + {string([]byte{0, 0, 0, 4}), "\\00\\00\\00\\04"}, + {"Chloé", "Chlo\\c3\\a9"}, + } + + for _, tc := range tests { + got := escapeFilter(tc.val) + if tc.want != got { + t.Errorf("value %q want=%q, got=%q", tc.val, tc.want, got) + } + } +}