diff --git a/builtin/credential/ldap/backend.go b/builtin/credential/ldap/backend.go index 9872aaed5..15e55fe15 100644 --- a/builtin/credential/ldap/backend.go +++ b/builtin/credential/ldap/backend.go @@ -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 = ` diff --git a/builtin/credential/ldap/backend_test.go b/builtin/credential/ldap/backend_test.go index 415e7edf9..c59e8ceed 100644 --- a/builtin/credential/ldap/backend_test.go +++ b/builtin/credential/ldap/backend_test.go @@ -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, diff --git a/builtin/credential/ldap/path_login.go b/builtin/credential/ldap/path_login.go index 41d66d04e..57cbc8185 100644 --- a/builtin/credential/ldap/path_login.go +++ b/builtin/credential/ldap/path_login.go @@ -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 } diff --git a/changelog/11000.txt b/changelog/11000.txt new file mode 100644 index 000000000..600ec424a --- /dev/null +++ b/changelog/11000.txt @@ -0,0 +1,3 @@ ++```release-note:improvement ++auth/ldap: include support for an optional user filter field when searching for users ++``` diff --git a/helper/testhelpers/ldap/ldaphelper.go b/helper/testhelpers/ldap/ldaphelper.go index c6c44bd90..394ef34da 100644 --- a/helper/testhelpers/ldap/ldaphelper.go +++ b/helper/testhelpers/ldap/ldaphelper.go @@ -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" diff --git a/helper/testhelpers/logical/testing.go b/helper/testhelpers/logical/testing.go index 196964534..7037d1592 100644 --- a/helper/testhelpers/logical/testing.go +++ b/helper/testhelpers/logical/testing.go @@ -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 { diff --git a/sdk/helper/ldaputil/client.go b/sdk/helper/ldaputil/client.go index 058ad4b45..5babd9e34 100644 --- a/sdk/helper/ldaputil/client.go +++ b/sdk/helper/ldaputil/client.go @@ -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: * * 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. - * 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) { 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 != "") { - 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)) - 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, - }) + 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. */ diff --git a/sdk/helper/ldaputil/config.go b/sdk/helper/ldaputil/config.go index 867347682..d9118d1b2 100644 --- a/sdk/helper/ldaputil/config.go +++ b/sdk/helper/ldaputil/config.go @@ -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, diff --git a/sdk/helper/ldaputil/config_test.go b/sdk/helper/ldaputil/config_test.go index 0516413d7..7463be363 100644 --- a/sdk/helper/ldaputil/config_test.go +++ b/sdk/helper/ldaputil/config_test.go @@ -152,6 +152,7 @@ var jsonConfigDefault = []byte(` "groupattr": "cn", "upndomain": "", "userattr": "cn", + "userfilter": "({{.UserAttr}}={{.Username}})", "certificate": "", "client_tls_cert": "", "client_tsl_key": "", diff --git a/ui/app/models/auth-config/ldap.js b/ui/app/models/auth-config/ldap.js index 18b801474..1f220be1b 100644 --- a/ui/app/models/auth-config/ldap.js +++ b/ui/app/models/auth-config/ldap.js @@ -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'], diff --git a/website/content/api-docs/auth/ldap.mdx b/website/content/api-docs/auth/ldap.mdx index 20e6bd8c9..da9c40a05 100644 --- a/website/content/api-docs/auth/ldap.mdx +++ b/website/content/api-docs/auth/ldap.mdx @@ -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). diff --git a/website/content/docs/auth/ldap.mdx b/website/content/docs/auth/ldap.mdx index e364c02f1..4a9ff57f4 100644 --- a/website/content/docs/auth/ldap.mdx +++ b/website/content/docs/auth/ldap.mdx @@ -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`.