Added support for a LDAP user search filter. Documentation, tests and UI included (#11000)

This commit is contained in:
Guillaume 2021-10-26 13:39:12 -04:00 committed by GitHub
parent 559816254b
commit b9b7f5a9a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 361 additions and 47 deletions

View File

@ -60,17 +60,17 @@ type backend struct {
*framework.Backend *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) cfg, err := b.Config(ctx, req)
if err != nil { if err != nil {
return nil, nil, nil, err return "", nil, nil, nil, err
} }
if cfg == nil { 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 { 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{ ldapClient := ldaputil.Client{
@ -80,10 +80,10 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri
c, err := ldapClient.DialLDAP(cfg.ConfigEntry) c, err := ldapClient.DialLDAP(cfg.ConfigEntry)
if err != nil { if err != nil {
return nil, logical.ErrorResponse(err.Error()), nil, nil return "", nil, logical.ErrorResponse(err.Error()), nil, nil
} }
if c == 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 // Clean connection
@ -94,7 +94,7 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri
if b.Logger().IsDebug() { if b.Logger().IsDebug() {
b.Logger().Debug("error getting user bind DN", "error", err) 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() { if b.Logger().IsDebug() {
@ -111,7 +111,7 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri
if b.Logger().IsDebug() { if b.Logger().IsDebug() {
b.Logger().Debug("ldap bind failed", "error", err) 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 // We re-bind to the BindDN if it's defined because we assume
@ -121,7 +121,7 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri
if b.Logger().IsDebug() { if b.Logger().IsDebug() {
b.Logger().Debug("error while attempting to re-bind with the BindDN User", "error", err) 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() { if b.Logger().IsDebug() {
b.Logger().Debug("re-bound to original binddn") b.Logger().Debug("re-bound to original binddn")
@ -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) userDN, err := ldapClient.GetUserDN(cfg.ConfigEntry, c, userBindDN, username)
if err != nil { if err != nil {
return nil, logical.ErrorResponse(err.Error()), nil, nil return "", nil, logical.ErrorResponse(err.Error()), nil, nil
} }
if cfg.AnonymousGroupSearch { if cfg.AnonymousGroupSearch {
c, err = ldapClient.DialLDAP(cfg.ConfigEntry) c, err = ldapClient.DialLDAP(cfg.ConfigEntry)
if err != nil { 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 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) ldapGroups, err := ldapClient.GetLdapGroups(cfg.ConfigEntry, c, userDN, username)
if err != nil { if err != nil {
return nil, logical.ErrorResponse(err.Error()), nil, nil return "", nil, logical.ErrorResponse(err.Error()), nil, nil
} }
if b.Logger().IsDebug() { if b.Logger().IsDebug() {
b.Logger().Debug("groups fetched from server", "num_server_groups", len(ldapGroups), "server_groups", ldapGroups) b.Logger().Debug("groups fetched from server", "num_server_groups", len(ldapGroups), "server_groups", ldapGroups)
@ -199,7 +199,16 @@ func (b *backend) Login(ctx context.Context, req *logical.Request, username stri
// Policies from each group may overlap // Policies from each group may overlap
policies = strutil.RemoveDuplicates(policies, true) 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 = ` const backendHelp = `

View File

@ -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) b := factory(t)
cleanup, cfg := ldap.PrepareTestContainer(t, "latest") cleanup, cfg := ldap.PrepareTestContainer(t, "latest")
defer cleanup() 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" 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 // Setup connection
client := &ldaputil.Client{ client := &ldaputil.Client{
Logger: hclog.New(&hclog.LoggerOptions{ Logger: hclog.New(&hclog.LoggerOptions{
@ -543,14 +635,23 @@ func TestBackend_basic_authbind_upndomain(t *testing.T) {
} }
// Modify professor user and add userPrincipalName attribute // Modify professor user and add userPrincipalName attribute
profDN := "cn=Hubert J. Farnsworth,ou=people,dc=planetexpress,dc=com" modifyUserReq := goldap.NewModifyRequest(testUserDN, nil)
modifyUserReq := goldap.NewModifyRequest(profDN, nil)
modifyUserReq.Add("objectClass", []string{"PrincipalNameClass"}) modifyUserReq.Add("objectClass", []string{"PrincipalNameClass"})
modifyUserReq.Add("userPrincipalName", []string{"professor@planetexpress.com"}) modifyUserReq.Add("userPrincipalName", []string{testUserUPN})
if err := conn.Modify(modifyUserReq); err != nil { if err := conn.Modify(modifyUserReq); err != nil {
t.Fatal(err) 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{ logicaltest.Test(t, logicaltest.TestCase{
CredentialBackend: b, CredentialBackend: b,
Steps: []logicaltest.TestStep{ Steps: []logicaltest.TestStep{
@ -647,6 +748,11 @@ func TestBackend_configDefaultsAfterUpdate(t *testing.T) {
t.Errorf("Default mismatch: userattr. Expected: '%s', received :'%s'", defaultUserAttr, cfg["userattr"]) 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 defaultDenyNullBind := true
if cfg["deny_null_bind"] != defaultDenyNullBind { if cfg["deny_null_bind"] != defaultDenyNullBind {
t.Errorf("Default mismatch: deny_null_bind. Expected: '%t', received :'%s'", defaultDenyNullBind, cfg["deny_null_bind"]) t.Errorf("Default mismatch: deny_null_bind. Expected: '%t', received :'%s'", defaultDenyNullBind, cfg["deny_null_bind"])
@ -667,6 +773,7 @@ func testAccStepConfigUrl(t *testing.T, cfg *ldaputil.ConfigEntry) logicaltest.T
"url": cfg.Url, "url": cfg.Url,
"userattr": cfg.UserAttr, "userattr": cfg.UserAttr,
"userdn": cfg.UserDN, "userdn": cfg.UserDN,
"userfilter": cfg.UserFilter,
"groupdn": cfg.GroupDN, "groupdn": cfg.GroupDN,
"groupattr": cfg.GroupAttr, "groupattr": cfg.GroupAttr,
"binddn": cfg.BindDN, "binddn": cfg.BindDN,
@ -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 { func testAccStepLoginNoAttachedPolicies(t *testing.T, user string, pass string) logicaltest.TestStep {
return logicaltest.TestStep{ return logicaltest.TestStep{
Operation: logical.UpdateOperation, Operation: logical.UpdateOperation,
@ -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 { func testAccStepLoginNoGroupDN(t *testing.T, user string, pass string) logicaltest.TestStep {
return logicaltest.TestStep{ return logicaltest.TestStep{
Operation: logical.UpdateOperation, Operation: logical.UpdateOperation,
@ -955,6 +1089,7 @@ func TestLdapAuthBackend_ConfigUpgrade(t *testing.T) {
"url": cfg.Url, "url": cfg.Url,
"userattr": cfg.UserAttr, "userattr": cfg.UserAttr,
"userdn": cfg.UserDN, "userdn": cfg.UserDN,
"userfilter": cfg.UserFilter,
"groupdn": cfg.GroupDN, "groupdn": cfg.GroupDN,
"groupattr": cfg.GroupAttr, "groupattr": cfg.GroupAttr,
"binddn": cfg.BindDN, "binddn": cfg.BindDN,
@ -990,6 +1125,7 @@ func TestLdapAuthBackend_ConfigUpgrade(t *testing.T) {
ConfigEntry: &ldaputil.ConfigEntry{ ConfigEntry: &ldaputil.ConfigEntry{
Url: cfg.Url, Url: cfg.Url,
UserAttr: cfg.UserAttr, UserAttr: cfg.UserAttr,
UserFilter: cfg.UserFilter,
UserDN: cfg.UserDN, UserDN: cfg.UserDN,
GroupDN: cfg.GroupDN, GroupDN: cfg.GroupDN,
GroupAttr: cfg.GroupAttr, GroupAttr: cfg.GroupAttr,

View File

@ -73,7 +73,7 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, d *framew
username := d.Get("username").(string) username := d.Get("username").(string)
password := d.Get("password").(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 // Handle an internal error
if err != nil { if err != nil {
return nil, err return nil, err
@ -96,7 +96,7 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, d *framew
}, },
DisplayName: username, DisplayName: username,
Alias: &logical.Alias{ Alias: &logical.Alias{
Name: username, Name: effectiveUsername,
}, },
} }
@ -132,7 +132,7 @@ func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, d *f
username := req.Auth.Metadata["username"] username := req.Auth.Metadata["username"]
password := req.Auth.InternalData["password"].(string) 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()) { if err != nil || (resp != nil && resp.IsError()) {
return resp, err return resp, err
} }

3
changelog/11000.txt Normal file
View File

@ -0,0 +1,3 @@
+```release-note:improvement
+auth/ldap: include support for an optional user filter field when searching for users
+```

View File

@ -27,6 +27,7 @@ func PrepareTestContainer(t *testing.T, version string) (cleanup func(), cfg *ld
cfg = new(ldaputil.ConfigEntry) cfg = new(ldaputil.ConfigEntry)
cfg.UserDN = "ou=people,dc=planetexpress,dc=com" cfg.UserDN = "ou=people,dc=planetexpress,dc=com"
cfg.UserAttr = "cn" cfg.UserAttr = "cn"
cfg.UserFilter = "({{.UserAttr}}={{.Username}})"
cfg.BindDN = "cn=admin,dc=planetexpress,dc=com" cfg.BindDN = "cn=admin,dc=planetexpress,dc=com"
cfg.BindPassword = "GoodNewsEveryone" cfg.BindPassword = "GoodNewsEveryone"
cfg.GroupDN = "ou=people,dc=planetexpress,dc=com" cfg.GroupDN = "ou=people,dc=planetexpress,dc=com"

View File

@ -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 // TestCheckAuthDisplayName is a helper to check that a request generated a
// valid display name. // valid display name.
func TestCheckAuthDisplayName(n string) TestCheckFunc { func TestCheckAuthDisplayName(n string) TestCheckFunc {

View File

@ -95,51 +95,76 @@ func (c *Client) DialLDAP(cfg *ConfigEntry) (Connection, error) {
} }
/* /*
* Discover and return the bind string for the user attempting to authenticate. * Searches for a username in the ldap server, returning a minimal subset of the
* user's attributes (if found)
*/
func (c *Client) makeLdapSearchRequest(cfg *ConfigEntry, conn Connection, username string) (*ldap.SearchResult, error) {
// Note: The logic below drives the logic in ConfigEntry.Validate().
// If updated, please update there as well.
var err error
if cfg.BindPassword != "" {
err = conn.Bind(cfg.BindDN, cfg.BindPassword)
} else {
err = conn.UnauthenticatedBind(cfg.BindDN)
}
if err != nil {
return nil, fmt.Errorf("LDAP bind (service) failed: %w", err)
}
renderedFilter, err := c.RenderUserSearchFilter(cfg, username)
if c.Logger.IsDebug() {
c.Logger.Debug("discovering user", "userdn", cfg.UserDN, "filter", renderedFilter)
}
ldapRequest := &ldap.SearchRequest{
BaseDN: cfg.UserDN,
Scope: ldap.ScopeWholeSubtree,
Filter: renderedFilter,
SizeLimit: 2, //Should be only 1 result. Any number larger (2 or more) means access denied.
Attributes: []string{
cfg.UserAttr, //Return only needed attributes
},
}
result, err := conn.Search(ldapRequest)
if err != nil {
return nil, err
}
return result, nil
}
/*
* Discover and return the bind string for the user attempting to authenticate, as well as the
* value to use for the identity alias.
* This is handled in one of several ways: * This is handled in one of several ways:
* *
* 1. If DiscoverDN is set, the user object will be searched for using userdn (base search path) * 1. If DiscoverDN is set, the user object will be searched for using userdn (base search path)
* and userattr (the attribute that maps to the provided username). * and userattr (the attribute that maps to the provided username) or user search filter.
* The bind will either be anonymous or use binddn and bindpassword if they were provided. * The bind will either be anonymous or use binddn and bindpassword if they were provided.
* 2. If upndomain is set, the user dn is constructed as 'username@upndomain'. See https://msdn.microsoft.com/en-us/library/cc223499.aspx * 2. If upndomain is set, the user dn and alias attribte are constructed as 'username@upndomain'.
* See https://msdn.microsoft.com/en-us/library/cc223499.aspx
* *
*/ */
func (c *Client) GetUserBindDN(cfg *ConfigEntry, conn Connection, username string) (string, error) { func (c *Client) GetUserBindDN(cfg *ConfigEntry, conn Connection, username string) (string, error) {
bindDN := "" bindDN := ""
// Note: The logic below drives the logic in ConfigEntry.Validate(). // Note: The logic below drives the logic in ConfigEntry.Validate().
// If updated, please update there as well. // If updated, please update there as well.
if cfg.DiscoverDN || (cfg.BindDN != "" && cfg.BindPassword != "") { if cfg.DiscoverDN || (cfg.BindDN != "" && cfg.BindPassword != "") {
var err error
if cfg.BindPassword != "" {
err = conn.Bind(cfg.BindDN, cfg.BindPassword)
} else {
err = conn.UnauthenticatedBind(cfg.BindDN)
}
if err != nil {
return bindDN, errwrap.Wrapf("LDAP bind (service) failed: {{err}}", err)
}
filter := fmt.Sprintf("(%s=%s)", cfg.UserAttr, ldap.EscapeFilter(username)) result, err := c.makeLdapSearchRequest(cfg, conn, username)
if cfg.UPNDomain != "" {
filter = fmt.Sprintf("(userPrincipalName=%s@%s)", EscapeLDAPValue(username), cfg.UPNDomain)
}
if c.Logger.IsDebug() {
c.Logger.Debug("discovering user", "userdn", cfg.UserDN, "filter", filter)
}
result, err := conn.Search(&ldap.SearchRequest{
BaseDN: cfg.UserDN,
Scope: ldap.ScopeWholeSubtree,
Filter: filter,
SizeLimit: math.MaxInt32,
})
if err != nil { if err != nil {
return bindDN, errwrap.Wrapf("LDAP search for binddn failed: {{err}}", err) return bindDN, errwrap.Wrapf("LDAP search for binddn failed: {{err}}", err)
} }
if len(result.Entries) != 1 { if len(result.Entries) != 1 {
return bindDN, fmt.Errorf("LDAP search for binddn 0 or not unique") return bindDN, fmt.Errorf("LDAP search for binddn 0 or not unique")
} }
bindDN = result.Entries[0].DN bindDN = result.Entries[0].DN
} else { } else {
if cfg.UPNDomain != "" { if cfg.UPNDomain != "" {
bindDN = fmt.Sprintf("%s@%s", EscapeLDAPValue(username), cfg.UPNDomain) bindDN = fmt.Sprintf("%s@%s", EscapeLDAPValue(username), cfg.UPNDomain)
@ -151,6 +176,92 @@ func (c *Client) GetUserBindDN(cfg *ConfigEntry, conn Connection, username strin
return bindDN, nil return bindDN, nil
} }
func (c *Client) RenderUserSearchFilter(cfg *ConfigEntry, username string) (string, error) {
// The UserFilter can be blank if not set, or running this version of the code
// on an existing ldap configuration
if cfg.UserFilter == "" {
cfg.UserFilter = "({{.UserAttr}}={{.Username}})"
}
// If userfilter was defined, resolve it as a Go template and use the query to
// find the login user
if c.Logger.IsDebug() {
c.Logger.Debug("compiling search filter", "search_filter", cfg.UserFilter)
}
// Parse the configuration as a template.
// Example template "({{.UserAttr}}={{.Username}})"
t, err := template.New("queryTemplate").Parse(cfg.UserFilter)
if err != nil {
return "", errwrap.Wrapf("LDAP search failed due to template compilation error: {{err}}", err)
}
// Build context to pass to template - we will be exposing UserDn and Username.
context := struct {
UserAttr string
Username string
}{
ldap.EscapeFilter(cfg.UserAttr),
ldap.EscapeFilter(username),
}
if cfg.UPNDomain != "" {
context.UserAttr = "userPrincipalName"
context.Username = fmt.Sprintf("%s@%s", EscapeLDAPValue(username), cfg.UPNDomain)
}
var renderedFilter bytes.Buffer
if err := t.Execute(&renderedFilter, context); err != nil {
return "", errwrap.Wrapf("LDAP search failed due to template parsing error: {{err}}", err)
}
return renderedFilter.String(), nil
}
/*
* Returns the value to be used for the entity alias of this user
* This is handled in one of several ways:
*
* 1. If DiscoverDN is set, the user will be searched for using userdn (base search path)
* and userattr (the attribute that maps to the provided username) or user search filter.
* The bind will either be anonymous or use binddn and bindpassword if they were provided.
* 2. If upndomain is set, the alias attribte is constructed as 'username@upndomain'.
*
*/
func (c *Client) GetUserAliasAttributeValue(cfg *ConfigEntry, conn Connection, username string) (string, error) {
aliasAttributeValue := ""
// Note: The logic below drives the logic in ConfigEntry.Validate().
// If updated, please update there as well.
if cfg.DiscoverDN || (cfg.BindDN != "" && cfg.BindPassword != "") {
result, err := c.makeLdapSearchRequest(cfg, conn, username)
if err != nil {
return aliasAttributeValue, errwrap.Wrapf("LDAP search for entity alias attribute failed: {{err}}", err)
}
if len(result.Entries) != 1 {
return aliasAttributeValue, fmt.Errorf("LDAP search for entity alias attribute 0 or not unique")
}
if len(result.Entries[0].Attributes) != 1 {
return aliasAttributeValue, errwrap.Wrapf("LDAP attribute missing for entity alias mapping{{err}}", err)
}
if len(result.Entries[0].Attributes[0].Values) != 1 {
return aliasAttributeValue, fmt.Errorf("LDAP entity alias attribute %s empty or not unique for entity alias mapping", cfg.UserAttr)
}
aliasAttributeValue = result.Entries[0].Attributes[0].Values[0]
} else {
if cfg.UPNDomain != "" {
aliasAttributeValue = fmt.Sprintf("%s@%s", EscapeLDAPValue(username), cfg.UPNDomain)
} else {
aliasAttributeValue = fmt.Sprintf("%s=%s,%s", cfg.UserAttr, EscapeLDAPValue(username), cfg.UserDN)
}
}
return aliasAttributeValue, nil
}
/* /*
* Returns the DN of the object representing the authenticated user. * Returns the DN of the object representing the authenticated user.
*/ */

View File

@ -93,6 +93,17 @@ Default: cn`,
}, },
}, },
"userfilter": {
Type: framework.TypeString,
Default: "({{.UserAttr}}={{.Username}})",
Description: `Go template for LDAP user search filer (optional)
The template can access the following context variables: UserAttr, Username
Default: ({{.UserAttr}}={{.Username}})`,
DisplayAttrs: &framework.DisplayAttributes{
Name: "User Search Filter",
},
},
"upndomain": { "upndomain": {
Type: framework.TypeString, Type: framework.TypeString,
Description: "Enables userPrincipalDomain login with [username]@UPNDomain (optional)", Description: "Enables userPrincipalDomain login with [username]@UPNDomain (optional)",
@ -235,6 +246,19 @@ func NewConfigEntry(existing *ConfigEntry, d *framework.FieldData) (*ConfigEntry
cfg.Url = strings.ToLower(d.Get("url").(string)) cfg.Url = strings.ToLower(d.Get("url").(string))
} }
if _, ok := d.Raw["userfilter"]; ok || !hadExisting {
userfilter := d.Get("userfilter").(string)
if userfilter != "" {
// Validate the template before proceeding
_, err := template.New("queryTemplate").Parse(userfilter)
if err != nil {
return nil, errwrap.Wrapf("invalid userfilter: {{err}}", err)
}
}
cfg.UserFilter = userfilter
}
if _, ok := d.Raw["userattr"]; ok || !hadExisting { if _, ok := d.Raw["userattr"]; ok || !hadExisting {
cfg.UserAttr = strings.ToLower(d.Get("userattr").(string)) cfg.UserAttr = strings.ToLower(d.Get("userattr").(string))
} }
@ -369,6 +393,7 @@ type ConfigEntry struct {
GroupFilter string `json:"groupfilter"` GroupFilter string `json:"groupfilter"`
GroupAttr string `json:"groupattr"` GroupAttr string `json:"groupattr"`
UPNDomain string `json:"upndomain"` UPNDomain string `json:"upndomain"`
UserFilter string `json:"userfilter"`
UserAttr string `json:"userattr"` UserAttr string `json:"userattr"`
Certificate string `json:"certificate"` Certificate string `json:"certificate"`
InsecureTLS bool `json:"insecure_tls"` InsecureTLS bool `json:"insecure_tls"`
@ -405,6 +430,7 @@ func (c *ConfigEntry) PasswordlessMap() map[string]interface{} {
"groupdn": c.GroupDN, "groupdn": c.GroupDN,
"groupfilter": c.GroupFilter, "groupfilter": c.GroupFilter,
"groupattr": c.GroupAttr, "groupattr": c.GroupAttr,
"userfilter": c.UserFilter,
"upndomain": c.UPNDomain, "upndomain": c.UPNDomain,
"userattr": c.UserAttr, "userattr": c.UserAttr,
"certificate": c.Certificate, "certificate": c.Certificate,

View File

@ -152,6 +152,7 @@ var jsonConfigDefault = []byte(`
"groupattr": "cn", "groupattr": "cn",
"upndomain": "", "upndomain": "",
"userattr": "cn", "userattr": "cn",
"userfilter": "({{.UserAttr}}={{.Username}})",
"certificate": "", "certificate": "",
"client_tls_cert": "", "client_tls_cert": "",
"client_tsl_key": "", "client_tsl_key": "",

View File

@ -33,7 +33,7 @@ export default AuthConfig.extend({
], ],
}, },
{ {
'Customize User Search': ['binddn', 'userdn', 'bindpass'], 'Customize User Search': ['binddn', 'userdn', 'bindpass', 'userfilter'],
}, },
{ {
'Customize Group Membership Search': ['groupfilter', 'groupattr', 'groupdn', 'useTokenGroups'], 'Customize Group Membership Search': ['groupfilter', 'groupattr', 'groupdn', 'useTokenGroups'],

View File

@ -66,6 +66,10 @@ This endpoint configures the LDAP auth method.
string for the authenticating user. The constructed UPN will appear as string for the authenticating user. The constructed UPN will appear as
`[username]@UPNDomain`. Example: `example.com`, which will cause vault to bind `[username]@UPNDomain`. Example: `example.com`, which will cause vault to bind
as `username@example.com`. as `username@example.com`.
- `userfilter` `(string: "")` An optional LDAP user search filter.
The template can access the following context variables: UserAttr, Username.
The default is `({{.UserAttr}}={{.Username}})`, or `({{.UserAttr}}={{.Username@.upndomain}})`
if `upndomain` is set.
- `anonymous_group_search` `(bool: false)` - Use anonymous binds when performing - `anonymous_group_search` `(bool: false)` - Use anonymous binds when performing
LDAP group searches (note: even when `true`, the initial credentials will still LDAP group searches (note: even when `true`, the initial credentials will still
be used for the initial connection test). be used for the initial connection test).

View File

@ -116,12 +116,16 @@ There are two alternate methods of resolving the user object used to authenticat
- `bindpass` (string, optional) - Password to use along with `binddn` when performing user search. - `bindpass` (string, optional) - Password to use along with `binddn` when performing user search.
- `userdn` (string, optional) - Base DN under which to perform user search. Example: `ou=Users,dc=example,dc=com` - `userdn` (string, optional) - Base DN under which to perform user search. Example: `ou=Users,dc=example,dc=com`
- `userattr` (string, optional) - Attribute on user attribute object matching the username passed when authenticating. Examples: `sAMAccountName`, `cn`, `uid` - `userattr` (string, optional) - Attribute on user attribute object matching the username passed when authenticating. Examples: `sAMAccountName`, `cn`, `uid`
- `userfilter` (string, optional) - Go template used to construct a ldap user search filter. The template can access the following context variables: \[`UserAttr`, `Username`\]. The default userfilter is `({{.UserAttr}}={{.Username}})` or `(userPrincipalName={{.Username}}@UPNDomain)` if the `upndomain` parameter is set. The user search filter can be used to restrict what user can attempt to log in. For example, to limit login to users that are not contractors, you could write `(&(objectClass=user)({{.UserAttr}}={{.Username}})(!(employeeType=Contractor)))`.
#### Binding - Anonymous Search #### Binding - Anonymous Search
- `discoverdn` (bool, optional) - If true, use anonymous bind to discover the bind DN of a user - `discoverdn` (bool, optional) - If true, use anonymous bind to discover the bind DN of a user
- `userdn` (string, optional) - Base DN under which to perform user search. Example: `ou=Users,dc=example,dc=com` - `userdn` (string, optional) - Base DN under which to perform user search. Example: `ou=Users,dc=example,dc=com`
- `userattr` (string, optional) - Attribute on user attribute object matching the username passed when authenticating. Examples: `sAMAccountName`, `cn`, `uid` - `userattr` (string, optional) - Attribute on user attribute object matching the username passed when authenticating. Examples: `sAMAccountName`, `cn`, `uid`
- `userfilter` (string, optional) - Go template used to construct a ldap user search filter. The template can access the following context variables: \[`UserAttr`, `Username`\]. The default userfilter is `({{.UserAttr}}={{.Username}})` or `(userPrincipalName={{.Username}}@UPNDomain)` if the `upndomain` parameter is set. The user search filter can be used to restrict what user can attempt to log in. For example, to limit login to users that are not contractors, you could write `(&(objectClass=user)({{.UserAttr}}={{.Username}})(!(employeeType=Contractor)))`.
- `deny_null_bind` (bool, optional) - This option prevents users from bypassing authentication when providing an empty password. The default is `true`. - `deny_null_bind` (bool, optional) - This option prevents users from bypassing authentication when providing an empty password. The default is `true`.
- `anonymous_group_search` (bool, optional) - Use anonymous binds when performing LDAP group searches. Defaults to `false`. - `anonymous_group_search` (bool, optional) - Use anonymous binds when performing LDAP group searches. Defaults to `false`.