diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go index 4cb7180e..e01e2df8 100644 --- a/connector/ldap/ldap.go +++ b/connector/ldap/ldap.go @@ -34,10 +34,12 @@ import ( // bindDN: uid=serviceaccount,cn=users,dc=example,dc=com // bindPW: password // userSearch: -// # Would translate to the query "(&(objectClass=person)(uid=))" +// # Would translate to the query "(&(objectClass=person)(|(uid=)(mail=)))" // baseDN: cn=users,dc=example,dc=com // filter: "(objectClass=person)" -// username: uid +// username: +// - uid +// - mail // idAttr: uid // emailAttr: mail // nameAttr: name @@ -58,6 +60,27 @@ import ( // nameAttr: name // +// UsernameAttributes represents one or more LDAP attributes to match against +// the username input. It supports unmarshaling from both a single string +// (e.g. "uid") and a list of strings (e.g. ["uid", "mail"]). +type UsernameAttributes []string + +func (u *UsernameAttributes) UnmarshalJSON(data []byte) error { + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *u = arr + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("username must be a string or list of strings") + } + if s != "" { + *u = UsernameAttributes{s} + } + return nil +} + // UserMatcher holds information about user and group matching. type UserMatcher struct { UserAttr string `json:"userAttr"` @@ -110,9 +133,10 @@ type Config struct { // Optional filter to apply when searching the directory. For example "(objectClass=person)" Filter string `json:"filter"` - // Attribute to match against the inputted username. This will be translated and combined - // with the other filter as "(=)". - Username string `json:"username"` + // Attribute(s) to match against the inputted username. Accepts a single string + // or a list of strings. When multiple attributes are specified, an OR filter is + // constructed: "(|(=)(=))". + Username UsernameAttributes `json:"username"` // Can either be: // * "sub" - search the whole sub tree @@ -242,7 +266,6 @@ func (c *Config) openConnector(logger *slog.Logger) (*ldapConnector, error) { }{ {"host", c.Host}, {"userSearch.baseDN", c.UserSearch.BaseDN}, - {"userSearch.username", c.UserSearch.Username}, } for _, field := range requiredFields { @@ -251,6 +274,10 @@ func (c *Config) openConnector(logger *slog.Logger) (*ldapConnector, error) { } } + if len(c.UserSearch.Username) == 0 { + return nil, fmt.Errorf("ldap: missing required field %q", "userSearch.username") + } + var ( host string err error @@ -298,7 +325,7 @@ func (c *Config) openConnector(logger *slog.Logger) (*ldapConnector, error) { // TODO(nabokihms): remove it after deleting deprecated groupSearch options c.GroupSearch.UserMatchers = userMatchers(c, logger) - return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig, logger}, nil + return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig, c.UserSearch.Username, logger}, nil } var ( @@ -314,6 +341,8 @@ type ldapConnector struct { tlsConfig *tls.Config + usernameAttrs []string + logger *slog.Logger } @@ -421,7 +450,19 @@ func (c *ldapConnector) identityFromEntry(user ldap.Entry) (ident connector.Iden } func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.Entry, found bool, err error) { - filter := fmt.Sprintf("(%s=%s)", c.UserSearch.Username, ldap.EscapeFilter(username)) + var filter string + escapedUsername := ldap.EscapeFilter(username) + + attrFilters := make([]string, 0, len(c.usernameAttrs)) + for _, attr := range c.usernameAttrs { + attrFilters = append(attrFilters, fmt.Sprintf("(%s=%s)", attr, escapedUsername)) + } + if len(attrFilters) == 1 { + filter = attrFilters[0] // Skip OR wrapper for single attribute + } else { + filter = fmt.Sprintf("(|%s)", strings.Join(attrFilters, "")) + } + if c.UserSearch.Filter != "" { filter = fmt.Sprintf("(&%s%s)", c.UserSearch.Filter, filter) } @@ -439,6 +480,8 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E }, } + req.Attributes = append(req.Attributes, c.usernameAttrs...) + for _, matcher := range c.GroupSearch.UserMatchers { req.Attributes = append(req.Attributes, matcher.UserAttr) } diff --git a/connector/ldap/ldap_test.go b/connector/ldap/ldap_test.go index a9665e12..3335d56b 100644 --- a/connector/ldap/ldap_test.go +++ b/connector/ldap/ldap_test.go @@ -45,7 +45,7 @@ func TestQuery(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} tests := []subtest{ { @@ -105,7 +105,7 @@ func TestQueryWithEmailSuffix(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailSuffix = "test.example.com" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} tests := []subtest{ { @@ -141,7 +141,7 @@ func TestUserFilter(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.UserSearch.Filter = "(ou:dn:=Seattle)" tests := []subtest{ @@ -184,13 +184,50 @@ func TestUserFilter(t *testing.T) { runTests(t, connectLDAP, c, tests) } +func TestUsernameWithMultipleAttributes(t *testing.T) { + c := &Config{} + c.UserSearch.BaseDN = "ou=TestUsernameWithMultipleAttributes,dc=example,dc=org" + c.UserSearch.NameAttr = "cn" + c.UserSearch.EmailAttr = "mail" + c.UserSearch.IDAttr = "DN" + c.UserSearch.Username = UsernameAttributes{"cn", "mail"} + c.UserSearch.Filter = "(ou:dn:=Seattle)" + + tests := []subtest{ + { + name: "cn", + username: "jane", + password: "foo", + want: connector.Identity{ + UserID: "cn=jane,ou=People,ou=Seattle,ou=TestUsernameWithMultipleAttributes,dc=example,dc=org", + Username: "jane", + Email: "janedoe@example.com", + EmailVerified: true, + }, + }, + { + name: "mail", + username: "janedoe@example.com", + password: "foo", + want: connector.Identity{ + UserID: "cn=jane,ou=People,ou=Seattle,ou=TestUsernameWithMultipleAttributes,dc=example,dc=org", + Username: "jane", + Email: "janedoe@example.com", + EmailVerified: true, + }, + }, + } + + runTests(t, connectLDAP, c, tests) +} + func TestGroupQuery(t *testing.T) { c := &Config{} c.UserSearch.BaseDN = "ou=People,ou=TestGroupQuery,dc=example,dc=org" c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=Groups,ou=TestGroupQuery,dc=example,dc=org" c.GroupSearch.UserMatchers = []UserMatcher{ { @@ -238,7 +275,7 @@ func TestGroupsOnUserEntity(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=Groups,ou=TestGroupsOnUserEntity,dc=example,dc=org" c.GroupSearch.UserMatchers = []UserMatcher{ { @@ -284,7 +321,7 @@ func TestGroupFilter(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=TestGroupFilter,dc=example,dc=org" c.GroupSearch.UserMatchers = []UserMatcher{ { @@ -333,7 +370,7 @@ func TestGroupToUserMatchers(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=TestGroupToUserMatchers,dc=example,dc=org" c.GroupSearch.UserMatchers = []UserMatcher{ { @@ -389,7 +426,7 @@ func TestDeprecatedGroupToUserMatcher(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org" c.GroupSearch.UserAttr = "DN" c.GroupSearch.GroupAttr = "member" @@ -434,7 +471,7 @@ func TestStartTLS(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} tests := []subtest{ { @@ -458,7 +495,7 @@ func TestInsecureSkipVerify(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} tests := []subtest{ { @@ -482,7 +519,7 @@ func TestLDAPS(t *testing.T) { c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} tests := []subtest{ { @@ -525,13 +562,43 @@ func TestUsernamePrompt(t *testing.T) { } } +func TestUsernameAttributesUnmarshal(t *testing.T) { + tests := []struct { + name string + json string + want UsernameAttributes + wantErr bool + }{ + {name: "single string", json: `"uid"`, want: UsernameAttributes{"uid"}}, + {name: "array of strings", json: `["uid","mail"]`, want: UsernameAttributes{"uid", "mail"}}, + {name: "single element array", json: `["cn"]`, want: UsernameAttributes{"cn"}}, + {name: "empty string", json: `""`, want: nil}, + {name: "invalid type", json: `123`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got UsernameAttributes + err := got.UnmarshalJSON([]byte(tt.json)) + if (err != nil) != tt.wantErr { + t.Fatalf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr { + if diff := pretty.Compare(tt.want, got); diff != "" { + t.Errorf("unexpected result: %s", diff) + } + } + }) + } +} + func TestNestedGroups(t *testing.T) { c := &Config{} c.UserSearch.BaseDN = "ou=People,ou=TestNestedGroups,dc=example,dc=org" c.UserSearch.NameAttr = "cn" c.UserSearch.EmailAttr = "mail" c.UserSearch.IDAttr = "DN" - c.UserSearch.Username = "cn" + c.UserSearch.Username = UsernameAttributes{"cn"} c.GroupSearch.BaseDN = "ou=TestNestedGroups,dc=example,dc=org" c.GroupSearch.UserMatchers = []UserMatcher{ diff --git a/connector/ldap/testdata/schema.ldif b/connector/ldap/testdata/schema.ldif index a7f1393d..ab133543 100644 --- a/connector/ldap/testdata/schema.ldif +++ b/connector/ldap/testdata/schema.ldif @@ -505,3 +505,25 @@ objectClass: groupOfNames cn: circularGroup2 member: cn=circularGroup1,ou=Groups,ou=TestNestedGroups,dc=example,dc=org member: cn=john,ou=People,ou=TestNestedGroups,dc=example,dc=org + +######################################################################## + +dn: ou=TestUsernameWithMultipleAttributes,dc=example,dc=org +objectClass: organizationalUnit +ou: TestUsernameWithMultipleAttributes + +dn: ou=Seattle,ou=TestUsernameWithMultipleAttributes,dc=example,dc=org +objectClass: organizationalUnit +ou: Seattle + +dn: ou=People,ou=Seattle,ou=TestUsernameWithMultipleAttributes,dc=example,dc=org +objectClass: organizationalUnit +ou: People + +dn: cn=jane,ou=People,ou=Seattle,ou=TestUsernameWithMultipleAttributes,dc=example,dc=org +objectClass: person +objectClass: inetOrgPerson +sn: doe +cn: jane +mail: janedoe@example.com +userpassword: foo \ No newline at end of file