Skip to content

Commit

Permalink
Added support for a LDAP user search filter. Documentation, tests and…
Browse files Browse the repository at this point in the history
… UI included (#11000)
  • Loading branch information
ixe013 committed Oct 26, 2021
1 parent 07f1409 commit 4cc2673
Show file tree
Hide file tree
Showing 12 changed files with 361 additions and 47 deletions.
35 changes: 22 additions & 13 deletions builtin/credential/ldap/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,17 @@ type backend struct {
*framework.Backend
}

func (b *backend) Login(ctx context.Context, req *logical.Request, username string, password string) ([]string, *logical.Response, []string, error) {
func (b *backend) Login(ctx context.Context, req *logical.Request, username string, password string) (string, []string, *logical.Response, []string, error) {
cfg, err := b.Config(ctx, req)
if err != nil {
return nil, nil, nil, err
return "", nil, nil, nil, err
}
if cfg == nil {
return nil, logical.ErrorResponse("ldap backend not configured"), nil, nil
return "", nil, logical.ErrorResponse("ldap backend not configured"), nil, nil
}

if cfg.DenyNullBind && len(password) == 0 {
return nil, logical.ErrorResponse("password cannot be of zero length when passwordless binds are being denied"), nil, nil
return "", nil, logical.ErrorResponse("password cannot be of zero length when passwordless binds are being denied"), nil, nil
}

ldapClient := ldaputil.Client{
Expand All @@ -80,10 +80,10 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri

c, err := ldapClient.DialLDAP(cfg.ConfigEntry)
if err != nil {
return nil, logical.ErrorResponse(err.Error()), nil, nil
return "", nil, logical.ErrorResponse(err.Error()), nil, nil
}
if c == nil {
return nil, logical.ErrorResponse("invalid connection returned from LDAP dial"), nil, nil
return "", nil, logical.ErrorResponse("invalid connection returned from LDAP dial"), nil, nil
}

// Clean connection
Expand All @@ -94,7 +94,7 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri
if b.Logger().IsDebug() {
b.Logger().Debug("error getting user bind DN", "error", err)
}
return nil, logical.ErrorResponse(errUserBindFailed), nil, nil
return "", nil, logical.ErrorResponse(errUserBindFailed), nil, nil
}

if b.Logger().IsDebug() {
Expand All @@ -111,7 +111,7 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri
if b.Logger().IsDebug() {
b.Logger().Debug("ldap bind failed", "error", err)
}
return nil, logical.ErrorResponse(errUserBindFailed), nil, nil
return "", nil, logical.ErrorResponse(errUserBindFailed), nil, nil
}

// We re-bind to the BindDN if it's defined because we assume
Expand All @@ -121,7 +121,7 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri
if b.Logger().IsDebug() {
b.Logger().Debug("error while attempting to re-bind with the BindDN User", "error", err)
}
return nil, logical.ErrorResponse("ldap operation failed: failed to re-bind with the BindDN user"), nil, nil
return "", nil, logical.ErrorResponse("ldap operation failed: failed to re-bind with the BindDN user"), nil, nil
}
if b.Logger().IsDebug() {
b.Logger().Debug("re-bound to original binddn")
Expand All @@ -130,20 +130,20 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri

userDN, err := ldapClient.GetUserDN(cfg.ConfigEntry, c, userBindDN, username)
if err != nil {
return nil, logical.ErrorResponse(err.Error()), nil, nil
return "", nil, logical.ErrorResponse(err.Error()), nil, nil
}

if cfg.AnonymousGroupSearch {
c, err = ldapClient.DialLDAP(cfg.ConfigEntry)
if err != nil {
return nil, logical.ErrorResponse("ldap operation failed: failed to connect to LDAP server"), nil, nil
return "", nil, logical.ErrorResponse("ldap operation failed: failed to connect to LDAP server"), nil, nil
}
defer c.Close() // Defer closing of this connection as the deferal above closes the other defined connection
}

ldapGroups, err := ldapClient.GetLdapGroups(cfg.ConfigEntry, c, userDN, username)
if err != nil {
return nil, logical.ErrorResponse(err.Error()), nil, nil
return "", nil, logical.ErrorResponse(err.Error()), nil, nil
}
if b.Logger().IsDebug() {
b.Logger().Debug("groups fetched from server", "num_server_groups", len(ldapGroups), "server_groups", ldapGroups)
Expand Down Expand Up @@ -199,7 +199,16 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri
// Policies from each group may overlap
policies = strutil.RemoveDuplicates(policies, true)

return policies, ldapResponse, allGroups, nil
entityAliasAttribute, err := ldapClient.GetUserAliasAttributeValue(cfg.ConfigEntry, c, username)

if err != nil {
return "", nil, logical.ErrorResponse(err.Error()), nil, nil
}
if entityAliasAttribute == "" {
return "", nil, logical.ErrorResponse("missing entity alias attribute value"), nil, nil
}

return entityAliasAttribute, policies, ldapResponse, allGroups, nil
}

const backendHelp = `
Expand Down
144 changes: 140 additions & 4 deletions builtin/credential/ldap/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,12 +500,104 @@ func TestBackend_basic_authbind(t *testing.T) {
})
}

func TestBackend_basic_authbind_upndomain(t *testing.T) {
func TestBackend_basic_authbind_userfilter(t *testing.T) {

b := factory(t)
cleanup, cfg := ldap.PrepareTestContainer(t, "latest")
defer cleanup()

//Add a liberal user filter, allowing to log in with either cn or email
cfg.UserFilter = "(|({{.UserAttr}}={{.Username}})(mail={{.Username}}))"

logicaltest.Test(t, logicaltest.TestCase{
CredentialBackend: b,
Steps: []logicaltest.TestStep{
testAccStepConfigUrl(t, cfg),
// Create engineers group with no policies
testAccStepGroup(t, "engineers", ""),
// Map hermes conrad user with local engineers group
testAccStepUser(t, "hermes conrad", "engineers"),
// Authenticate with cn attribute
testAccStepLoginNoAttachedPolicies(t, "hermes conrad", "hermes"),
// Authenticate with mail attribute
testAccStepLoginNoAttachedPolicies(t, "hermes@planetexpress.com", "hermes"),
},
})

//A filter giving the same DN makes the entity_id the same
entity_id := ""

logicaltest.Test(t, logicaltest.TestCase{
CredentialBackend: b,
Steps: []logicaltest.TestStep{
testAccStepConfigUrl(t, cfg),
// Create engineers group with no policies
testAccStepGroup(t, "engineers", ""),
// Map hermes conrad user with local engineers group
testAccStepUser(t, "hermes conrad", "engineers"),
// Authenticate with cn attribute
testAccStepLoginReturnsSameEntity(t, "hermes conrad", "hermes", &entity_id),
// Authenticate with mail attribute
testAccStepLoginReturnsSameEntity(t, "hermes@planetexpress.com", "hermes", &entity_id),
},
})

//Missing entity alias attribute means access denied
cfg.UserAttr = "inexistent"
cfg.UserFilter = "(|({{.UserAttr}}={{.Username}})(mail={{.Username}}))"

logicaltest.Test(t, logicaltest.TestCase{
CredentialBackend: b,
Steps: []logicaltest.TestStep{
testAccStepConfigUrl(t, cfg),
// Authenticate with mail attribute will find DN but missing attribute means access denied
testAccStepLoginFailure(t, "hermes@planetexpress.com", "hermes"),
},
})
cfg.UserAttr = "cn"

//UPNDomain has precedence over userfilter, for backward compatibility
cfg.UPNDomain = "planetexpress.com"

addUPNAttributeToLDAPSchemaAndUser(t, cfg, "cn=Hubert J. Farnsworth,ou=people,dc=planetexpress,dc=com", "professor@planetexpress.com")

logicaltest.Test(t, logicaltest.TestCase{
CredentialBackend: b,
Steps: []logicaltest.TestStep{
testAccStepConfigUrlWithAuthBind(t, cfg),
testAccStepLoginNoAttachedPolicies(t, "professor", "professor"),
},
})

cfg.UPNDomain = ""

//Add a strict user filter, rejecting login of bureaucrats
cfg.UserFilter = "(&({{.UserAttr}}={{.Username}})(!(employeeType=Bureaucrat)))"

logicaltest.Test(t, logicaltest.TestCase{
CredentialBackend: b,
Steps: []logicaltest.TestStep{
testAccStepConfigUrl(t, cfg),
// Authenticate with cn attribute
testAccStepLoginFailure(t, "hermes conrad", "hermes"),
},
})

//Login fails when multiple user match search filter (using an incorrect filter on purporse)
cfg.UserFilter = "(objectClass=*)"
logicaltest.Test(t, logicaltest.TestCase{
CredentialBackend: b,
Steps: []logicaltest.TestStep{
//testAccStepConfigUrl(t, cfg),
testAccStepConfigUrlWithAuthBind(t, cfg),
// Authenticate with cn attribute
testAccStepLoginFailure(t, "hermes conrad", "hermes"),
},
})

}

func addUPNAttributeToLDAPSchemaAndUser(t *testing.T, cfg *ldaputil.ConfigEntry, testUserDN string, testUserUPN string) {
// Setup connection
client := &ldaputil.Client{
Logger: hclog.New(&hclog.LoggerOptions{
Expand Down Expand Up @@ -543,14 +635,23 @@ func TestBackend_basic_authbind_upndomain(t *testing.T) {
}

// Modify professor user and add userPrincipalName attribute
profDN := "cn=Hubert J. Farnsworth,ou=people,dc=planetexpress,dc=com"
modifyUserReq := goldap.NewModifyRequest(profDN, nil)
modifyUserReq := goldap.NewModifyRequest(testUserDN, nil)
modifyUserReq.Add("objectClass", []string{"PrincipalNameClass"})
modifyUserReq.Add("userPrincipalName", []string{"professor@planetexpress.com"})
modifyUserReq.Add("userPrincipalName", []string{testUserUPN})
if err := conn.Modify(modifyUserReq); err != nil {
t.Fatal(err)
}

}

func TestBackend_basic_authbind_upndomain(t *testing.T) {
b := factory(t)
cleanup, cfg := ldap.PrepareTestContainer(t, "latest")
defer cleanup()
cfg.UPNDomain = "planetexpress.com"

addUPNAttributeToLDAPSchemaAndUser(t, cfg, "cn=Hubert J. Farnsworth,ou=people,dc=planetexpress,dc=com", "professor@planetexpress.com")

logicaltest.Test(t, logicaltest.TestCase{
CredentialBackend: b,
Steps: []logicaltest.TestStep{
Expand Down Expand Up @@ -647,6 +748,11 @@ func TestBackend_configDefaultsAfterUpdate(t *testing.T) {
t.Errorf("Default mismatch: userattr. Expected: '%s', received :'%s'", defaultUserAttr, cfg["userattr"])
}

defaultUserFilter := "({{.UserAttr}}={{.Username}})"
if cfg["userfilter"] != defaultUserFilter {
t.Errorf("Default mismatch: userfilter. Expected: '%s', received :'%s'", defaultUserFilter, cfg["userfilter"])
}

defaultDenyNullBind := true
if cfg["deny_null_bind"] != defaultDenyNullBind {
t.Errorf("Default mismatch: deny_null_bind. Expected: '%t', received :'%s'", defaultDenyNullBind, cfg["deny_null_bind"])
Expand All @@ -667,6 +773,7 @@ func testAccStepConfigUrl(t *testing.T, cfg *ldaputil.ConfigEntry) logicaltest.T
"url": cfg.Url,
"userattr": cfg.UserAttr,
"userdn": cfg.UserDN,
"userfilter": cfg.UserFilter,
"groupdn": cfg.GroupDN,
"groupattr": cfg.GroupAttr,
"binddn": cfg.BindDN,
Expand Down Expand Up @@ -855,6 +962,20 @@ func testAccStepLogin(t *testing.T, user string, pass string) logicaltest.TestSt
}
}

func testAccStepLoginReturnsSameEntity(t *testing.T, user string, pass string, entity_id *string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
Path: "login/" + user,
Data: map[string]interface{}{
"password": pass,
},
Unauthenticated: true,

// Verifies user hermes conrad maps to groups via local group (engineers) as well as remote group (Scientists)
Check: logicaltest.TestCheckAuthEntityId(entity_id),
}
}

func testAccStepLoginNoAttachedPolicies(t *testing.T, user string, pass string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
Expand All @@ -869,6 +990,19 @@ func testAccStepLoginNoAttachedPolicies(t *testing.T, user string, pass string)
}
}

func testAccStepLoginFailure(t *testing.T, user string, pass string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
Path: "login/" + user,
Data: map[string]interface{}{
"password": pass,
},
Unauthenticated: true,

ErrorOk: true,
}
}

func testAccStepLoginNoGroupDN(t *testing.T, user string, pass string) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
Expand Down Expand Up @@ -955,6 +1089,7 @@ func TestLdapAuthBackend_ConfigUpgrade(t *testing.T) {
"url": cfg.Url,
"userattr": cfg.UserAttr,
"userdn": cfg.UserDN,
"userfilter": cfg.UserFilter,
"groupdn": cfg.GroupDN,
"groupattr": cfg.GroupAttr,
"binddn": cfg.BindDN,
Expand Down Expand Up @@ -990,6 +1125,7 @@ func TestLdapAuthBackend_ConfigUpgrade(t *testing.T) {
ConfigEntry: &ldaputil.ConfigEntry{
Url: cfg.Url,
UserAttr: cfg.UserAttr,
UserFilter: cfg.UserFilter,
UserDN: cfg.UserDN,
GroupDN: cfg.GroupDN,
GroupAttr: cfg.GroupAttr,
Expand Down
6 changes: 3 additions & 3 deletions builtin/credential/ldap/path_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, d *framew
username := d.Get("username").(string)
password := d.Get("password").(string)

policies, resp, groupNames, err := b.Login(ctx, req, username, password)
effectiveUsername, policies, resp, groupNames, err := b.Login(ctx, req, username, password)
// Handle an internal error
if err != nil {
return nil, err
Expand All @@ -96,7 +96,7 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, d *framew
},
DisplayName: username,
Alias: &logical.Alias{
Name: username,
Name: effectiveUsername,
},
}

Expand Down Expand Up @@ -132,7 +132,7 @@ func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, d *f
username := req.Auth.Metadata["username"]
password := req.Auth.InternalData["password"].(string)

loginPolicies, resp, groupNames, err := b.Login(ctx, req, username, password)
_, loginPolicies, resp, groupNames, err := b.Login(ctx, req, username, password)
if err != nil || (resp != nil && resp.IsError()) {
return resp, err
}
Expand Down
3 changes: 3 additions & 0 deletions changelog/11000.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
+```release-note:improvement
+auth/ldap: include support for an optional user filter field when searching for users
+```
1 change: 1 addition & 0 deletions helper/testhelpers/ldap/ldaphelper.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func PrepareTestContainer(t *testing.T, version string) (cleanup func(), cfg *ld
cfg = new(ldaputil.ConfigEntry)
cfg.UserDN = "ou=people,dc=planetexpress,dc=com"
cfg.UserAttr = "cn"
cfg.UserFilter = "({{.UserAttr}}={{.Username}})"
cfg.BindDN = "cn=admin,dc=planetexpress,dc=com"
cfg.BindPassword = "GoodNewsEveryone"
cfg.GroupDN = "ou=people,dc=planetexpress,dc=com"
Expand Down
19 changes: 19 additions & 0 deletions helper/testhelpers/logical/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,25 @@ func TestCheckAuth(policies []string) TestCheckFunc {
}
}

// TestCheckAuthEntityId is a helper to check that a request generated an
// auth token with the expected entity_id.
func TestCheckAuthEntityId(entity_id *string) TestCheckFunc {
return func(resp *logical.Response) error {
if resp == nil || resp.Auth == nil {
return fmt.Errorf("no auth in response")
}

if *entity_id == "" {
// If we don't know what the entity_id should be, just save it
*entity_id = resp.Auth.EntityID
} else if resp.Auth.EntityID != *entity_id {
return fmt.Errorf("entity_id %s does not match the expected value of %s", resp.Auth.EntityID, *entity_id)
}

return nil
}
}

// TestCheckAuthDisplayName is a helper to check that a request generated a
// valid display name.
func TestCheckAuthDisplayName(n string) TestCheckFunc {
Expand Down

0 comments on commit 4cc2673

Please sign in to comment.