mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
feat(ldap): allow specifying multiple attributes on username input (#4061)
In some use-cases, one would like to login using either their username or email. Administrators now don't have to choose a single field but may specify multiple fields to count as "username". Signed-off-by: Yarden Shoham <git@yardenshoham.com>
This commit is contained in:
+51
-8
@@ -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=<username>))"
|
||||
// # Would translate to the query "(&(objectClass=person)(|(uid=<username>)(mail=<username>)))"
|
||||
// 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 "(<attr>=<username>)".
|
||||
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: "(|(<attr1>=<username>)(<attr2>=<username>))".
|
||||
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)
|
||||
}
|
||||
|
||||
+79
-12
@@ -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{
|
||||
|
||||
Vendored
+22
@@ -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
|
||||
Reference in New Issue
Block a user