Added support for a LDAP user search filter. Documentation, tests and UI included (#11000)
This commit is contained in:
parent
559816254b
commit
b9b7f5a9a3
|
@ -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{
|
||||
|
@ -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
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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 = `
|
||||
|
|
|
@ -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{
|
||||
|
@ -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{
|
||||
|
@ -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"])
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
+```release-note:improvement
|
||||
+auth/ldap: include support for an optional user filter field when searching for users
|
||||
+```
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -95,20 +95,13 @@ func (c *Client) DialLDAP(cfg *ConfigEntry) (Connection, error) {
|
|||
}
|
||||
|
||||
/*
|
||||
* Discover and return the bind string for the user attempting to authenticate.
|
||||
* 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)
|
||||
* and userattr (the attribute that maps to the provided username).
|
||||
* 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
|
||||
*
|
||||
* Searches for a username in the ldap server, returning a minimal subset of the
|
||||
* user's attributes (if found)
|
||||
*/
|
||||
func (c *Client) GetUserBindDN(cfg *ConfigEntry, conn Connection, username string) (string, error) {
|
||||
bindDN := ""
|
||||
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.
|
||||
if cfg.DiscoverDN || (cfg.BindDN != "" && cfg.BindPassword != "") {
|
||||
var err error
|
||||
if cfg.BindPassword != "" {
|
||||
err = conn.Bind(cfg.BindDN, cfg.BindPassword)
|
||||
|
@ -116,30 +109,62 @@ func (c *Client) GetUserBindDN(cfg *ConfigEntry, conn Connection, username strin
|
|||
err = conn.UnauthenticatedBind(cfg.BindDN)
|
||||
}
|
||||
if err != nil {
|
||||
return bindDN, errwrap.Wrapf("LDAP bind (service) failed: {{err}}", err)
|
||||
return nil, fmt.Errorf("LDAP bind (service) failed: %w", err)
|
||||
}
|
||||
|
||||
filter := fmt.Sprintf("(%s=%s)", cfg.UserAttr, ldap.EscapeFilter(username))
|
||||
if cfg.UPNDomain != "" {
|
||||
filter = fmt.Sprintf("(userPrincipalName=%s@%s)", EscapeLDAPValue(username), cfg.UPNDomain)
|
||||
}
|
||||
renderedFilter, err := c.RenderUserSearchFilter(cfg, username)
|
||||
|
||||
if c.Logger.IsDebug() {
|
||||
c.Logger.Debug("discovering user", "userdn", cfg.UserDN, "filter", filter)
|
||||
c.Logger.Debug("discovering user", "userdn", cfg.UserDN, "filter", renderedFilter)
|
||||
}
|
||||
result, err := conn.Search(&ldap.SearchRequest{
|
||||
ldapRequest := &ldap.SearchRequest{
|
||||
BaseDN: cfg.UserDN,
|
||||
Scope: ldap.ScopeWholeSubtree,
|
||||
Filter: filter,
|
||||
SizeLimit: math.MaxInt32,
|
||||
})
|
||||
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:
|
||||
*
|
||||
* 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) 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 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) {
|
||||
bindDN := ""
|
||||
|
||||
// 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 bindDN, errwrap.Wrapf("LDAP search for binddn failed: {{err}}", err)
|
||||
}
|
||||
if len(result.Entries) != 1 {
|
||||
return bindDN, fmt.Errorf("LDAP search for binddn 0 or not unique")
|
||||
}
|
||||
|
||||
bindDN = result.Entries[0].DN
|
||||
|
||||
} else {
|
||||
if 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
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
|
|
|
@ -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": {
|
||||
Type: framework.TypeString,
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
cfg.UserAttr = strings.ToLower(d.Get("userattr").(string))
|
||||
}
|
||||
|
@ -369,6 +393,7 @@ type ConfigEntry struct {
|
|||
GroupFilter string `json:"groupfilter"`
|
||||
GroupAttr string `json:"groupattr"`
|
||||
UPNDomain string `json:"upndomain"`
|
||||
UserFilter string `json:"userfilter"`
|
||||
UserAttr string `json:"userattr"`
|
||||
Certificate string `json:"certificate"`
|
||||
InsecureTLS bool `json:"insecure_tls"`
|
||||
|
@ -405,6 +430,7 @@ func (c *ConfigEntry) PasswordlessMap() map[string]interface{} {
|
|||
"groupdn": c.GroupDN,
|
||||
"groupfilter": c.GroupFilter,
|
||||
"groupattr": c.GroupAttr,
|
||||
"userfilter": c.UserFilter,
|
||||
"upndomain": c.UPNDomain,
|
||||
"userattr": c.UserAttr,
|
||||
"certificate": c.Certificate,
|
||||
|
|
|
@ -152,6 +152,7 @@ var jsonConfigDefault = []byte(`
|
|||
"groupattr": "cn",
|
||||
"upndomain": "",
|
||||
"userattr": "cn",
|
||||
"userfilter": "({{.UserAttr}}={{.Username}})",
|
||||
"certificate": "",
|
||||
"client_tls_cert": "",
|
||||
"client_tsl_key": "",
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -66,6 +66,10 @@ This endpoint configures the LDAP auth method.
|
|||
string for the authenticating user. The constructed UPN will appear as
|
||||
`[username]@UPNDomain`. Example: `example.com`, which will cause vault to bind
|
||||
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
|
||||
LDAP group searches (note: even when `true`, the initial credentials will still
|
||||
be used for the initial connection test).
|
||||
|
|
|
@ -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.
|
||||
- `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`
|
||||
- `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
|
||||
|
||||
- `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`
|
||||
- `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`.
|
||||
- `anonymous_group_search` (bool, optional) - Use anonymous binds when performing LDAP group searches. Defaults to `false`.
|
||||
|
||||
|
|
Loading…
Reference in New Issue