From cd6d114e42f68b20557db97e31acbd4cd4a4f1c1 Mon Sep 17 00:00:00 2001 From: Oren Shomron Date: Sun, 8 May 2016 20:21:44 -0400 Subject: [PATCH] LDAP Auth Backend Overhaul -------------------------- Added new configuration option to ldap auth backend - groupfilter. GroupFilter accepts a Go template which will be used in conjunction with GroupDN for finding the groups a user is a member of. The template will be provided with context consisting of UserDN and Username. Simplified group membership lookup significantly to support multiple use-cases: * Enumerating groups via memberOf attribute on user object * Previous default behavior of querying groups based on member/memberUid/uniqueMember attributes * Custom queries to support nested groups in AD via LDAP_MATCHING_RULE_IN_CHAIN matchind rule There is now a new configuration option - groupattr - which specifies how to resolve group membership from the objects returned by the primary groupfilter query. Additional changes: * Clarify documentation for LDAP auth backend. * Reworked how default values are set, added tests * Removed Dial from LDAP config read. Network should not affect configuration. --- builtin/credential/ldap/backend.go | 203 ++++++++++++++++-------- builtin/credential/ldap/backend_test.go | 115 ++++++++++++-- builtin/credential/ldap/path_config.go | 130 +++++++++++---- website/source/docs/auth/ldap.html.md | 128 ++++++++++++--- 4 files changed, 445 insertions(+), 131 deletions(-) diff --git a/builtin/credential/ldap/backend.go b/builtin/credential/ldap/backend.go index 3c1f8913c..c8c418337 100644 --- a/builtin/credential/ldap/backend.go +++ b/builtin/credential/ldap/backend.go @@ -1,9 +1,9 @@ package ldap import ( + "bytes" "fmt" - - "strings" + "text/template" "github.com/go-ldap/ldap" "github.com/hashicorp/vault/helper/mfa" @@ -100,32 +100,35 @@ func (b *backend) Login(req *logical.Request, username string, password string) return nil, logical.ErrorResponse("invalid connection returned from LDAP dial"), nil } - bindDN, err := getBindDN(cfg, c, username) + bindDN, err := b.getBindDN(cfg, c, username) if err != nil { return nil, logical.ErrorResponse(err.Error()), nil } + b.Logger().Printf("[DEBUG] auth/ldap: BindDN for %s is %s", username, bindDN) + + // Try to bind as the login user. This is where the actual authentication takes place. if err = c.Bind(bindDN, password); err != nil { return nil, logical.ErrorResponse(fmt.Sprintf("LDAP bind failed: %v", err)), nil } - userDN, err := getUserDN(cfg, c, bindDN) + userDN, err := b.getUserDN(cfg, c, bindDN) if err != nil { return nil, logical.ErrorResponse(err.Error()), nil } - ldapGroups, err := getLdapGroups(cfg, c, userDN, username) + ldapGroups, err := b.getLdapGroups(cfg, c, userDN, username) if err != nil { return nil, logical.ErrorResponse(err.Error()), nil } + b.Logger().Printf("[DEBUG] auth/ldap: Server returned %d groups: %v", len(ldapGroups), ldapGroups) ldapResponse := &logical.Response{ Data: map[string]interface{}{}, } if len(ldapGroups) == 0 { errString := fmt.Sprintf( - "no LDAP groups found in userDN '%s' or groupDN '%s';only policies from locally-defined groups available", - cfg.UserDN, + "no LDAP groups found in groupDN '%s'; only policies from locally-defined groups available", cfg.GroupDN) ldapResponse.AddWarning(errString) } @@ -133,10 +136,11 @@ func (b *backend) Login(req *logical.Request, username string, password string) var allGroups []string // Import the custom added groups from ldap backend user, err := b.User(req.Storage, username) - if err == nil && user != nil { + if err == nil && user != nil && user.Groups != nil { + b.Logger().Printf("[DEBUG] auth/ldap: adding %d local groups: %v\n", len(user.Groups), user.Groups) allGroups = append(allGroups, user.Groups...) } - // add the LDAP groups + // Merge local and LDAP groups allGroups = append(allGroups, ldapGroups...) // Retrieve policies @@ -161,12 +165,49 @@ func (b *backend) Login(req *logical.Request, username string, password string) return policies, ldapResponse, nil } -func getBindDN(cfg *ConfigEntry, c *ldap.Conn, username string) (string, error) { +/* + * Parses a distinguished name and returns the CN portion. + * Given a non-conforming string (such as an already-extracted CN), + * it will be returned as-is. + */ +func (b *backend) getCN(dn string) string { + parsedDN, err := ldap.ParseDN(dn) + if err != nil || len(parsedDN.RDNs) == 0 { + // It was already a CN, return as-is + return dn + } + + for _, rdn := range parsedDN.RDNs { + for _, rdnAttr := range rdn.Attributes { + if rdnAttr.Type == "CN" { + return rdnAttr.Value + } + } + } + + // Default, return self + return dn +} + +/* + * 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 + * + */ +func (b *backend) getBindDN(cfg *ConfigEntry, c *ldap.Conn, username string) (string, error) { bindDN := "" if cfg.DiscoverDN || (cfg.BindDN != "" && cfg.BindPassword != "") { if err := c.Bind(cfg.BindDN, cfg.BindPassword); err != nil { return bindDN, fmt.Errorf("LDAP bind (service) failed: %v", err) } + + filter := fmt.Sprintf("(%s=%s)", cfg.UserAttr, ldap.EscapeFilter(username)) + b.Logger().Printf("[DEBUG] auth/ldap: Discovering user, BaseDN=%s, Filter=%s", cfg.UserDN, filter) result, err := c.Search(&ldap.SearchRequest{ BaseDN: cfg.UserDN, Scope: 2, // subtree @@ -190,14 +231,19 @@ func getBindDN(cfg *ConfigEntry, c *ldap.Conn, username string) (string, error) return bindDN, nil } -func getUserDN(cfg *ConfigEntry, c *ldap.Conn, bindDN string) (string, error) { +/* + * Returns the DN of the object representing the authenticated user. + */ +func (b *backend) getUserDN(cfg *ConfigEntry, c *ldap.Conn, bindDN string) (string, error) { userDN := "" if cfg.UPNDomain != "" { // Find the distinguished name for the user if userPrincipalName used for login + filter := fmt.Sprintf("(userPrincipalName=%s)", ldap.EscapeFilter(bindDN)) + b.Logger().Printf("[DEBUG] auth/ldap: Searching UPN, BaseDN=%s, Filter=%s", cfg.UserDN, filter) result, err := c.Search(&ldap.SearchRequest{ BaseDN: cfg.UserDN, Scope: 2, // subtree - Filter: fmt.Sprintf("(userPrincipalName=%s)", ldap.EscapeFilter(bindDN)), + Filter: filter, }) if err != nil { return userDN, fmt.Errorf("LDAP search failed for detecting user: %v", err) @@ -212,77 +258,100 @@ func getUserDN(cfg *ConfigEntry, c *ldap.Conn, bindDN string) (string, error) { return userDN, nil } -func getLdapGroups(cfg *ConfigEntry, c *ldap.Conn, userDN string, username string) ([]string, error) { +/* + * getLdapGroups queries LDAP and returns a slice describing the set of groups the authenticated user is a member of. + * + * The search query is constructed according to cfg.GroupFilter, and run in context of cfg.GroupDN. + * Groups will be resolved from the query results by following the attribute defined in cfg.GroupAttr. + * + * cfg.GroupFilter is a go template and is compiled with the following context: [UserDN, Username] + * UserDN - The DN of the authenticated user + * Username - The Username of the authenticated user + * + * Example: + * cfg.GroupFilter = "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}}))" + * cfg.GroupDN = "OU=Groups,DC=myorg,DC=com" + * cfg.GroupAttr = "cn" + * + * NOTE - If cfg.GroupFilter is empty, no query is performed and an empty result slice is returned. + * + */ +func (b *backend) getLdapGroups(cfg *ConfigEntry, c *ldap.Conn, userDN string, username string) ([]string, error) { // retrieve the groups in a string/bool map as a structure to avoid duplicates inside ldapMap := make(map[string]bool) - // Fetch the optional memberOf property values on the user object - // This is the most common method used in Active Directory setup to retrieve the groups + + if cfg.GroupFilter == "" { + b.Logger().Printf("[WARN] auth/ldap: GroupFilter is empty, will not query server") + return make([]string, 0), nil + } + + if cfg.GroupDN == "" { + b.Logger().Printf("[WARN] auth/ldap: GroupDN is empty, will not query server") + return make([]string, 0), nil + } + + // If groupfilter was defined, resolve it as a Go template and use the query for + // returning the user's groups + b.Logger().Printf("[DEBUG] auth/ldap: Compiling group filter %s", cfg.GroupFilter) + + // Parse the configuration as a template. + // Example template "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}}))" + t, err := template.New("queryTemplate").Parse(cfg.GroupFilter) + if err != nil { + return nil, fmt.Errorf("LDAP search failed due to template compilation error: %v", err) + } + + // Build context to pass to template - we will be exposing UserDn and Username. + context := struct { + UserDN string + Username string + }{ + ldap.EscapeFilter(userDN), + ldap.EscapeFilter(username), + } + + var renderedQuery bytes.Buffer + t.Execute(&renderedQuery, context) + + b.Logger().Printf("[DEBUG] auth/ldap: Searching GroupDN=%s, query=%s", cfg.GroupDN, renderedQuery.String()) + result, err := c.Search(&ldap.SearchRequest{ - BaseDN: userDN, - Scope: 0, // base scope to fetch only the userDN - Filter: "(cn=*)", // bogus filter, required to fetch the CN from userDN + BaseDN: cfg.GroupDN, + Scope: 2, // subtree + Filter: renderedQuery.String(), Attributes: []string{ - "memberOf", + cfg.GroupAttr, }, }) - // this check remains in case something happens with the ldap query or connection if err != nil { - return nil, fmt.Errorf("LDAP fetch of distinguishedName=%s failed: %v", userDN, err) + return nil, fmt.Errorf("LDAP search failed: %v", err) } - // if there are more than one entry, we consider the results irrelevant and ignore them - if len(result.Entries) == 1 { - for _, attr := range result.Entries[0].Attributes { - // Find the groups the user is member of from the 'memberOf' attribute extracting the CN - if attr.Name == "memberOf" { - for _, value := range attr.Values { - memberOfDN, err := ldap.ParseDN(value) - if err != nil || len(memberOfDN.RDNs) == 0 { - continue - } - for _, rdn := range memberOfDN.RDNs { - for _, rdnTypeAndValue := range rdn.Attributes { - if strings.EqualFold(rdnTypeAndValue.Type, "CN") { - ldapMap[rdnTypeAndValue.Value] = true - } - } - } - } + for _, e := range result.Entries { + dn, err := ldap.ParseDN(e.DN) + if err != nil || len(dn.RDNs) == 0 { + continue + } + + // Enumerate attributes of each result, parse out CN and add as group + values := e.GetAttributeValues(cfg.GroupAttr) + if len(values) > 0 { + for _, val := range values { + groupCN := b.getCN(val) + ldapMap[groupCN] = true } + } else { + // If groupattr didn't resolve, use self (enumerating group objects) + groupCN := b.getCN(e.DN) + ldapMap[groupCN] = true } } - // Find groups by searching in groupDN for any of the memberUid, member or uniqueMember attributes - // and retrieving the CN in the DN result - if cfg.GroupDN != "" { - result, err := c.Search(&ldap.SearchRequest{ - BaseDN: cfg.GroupDN, - Scope: 2, // subtree - Filter: fmt.Sprintf("(|(memberUid=%s)(member=%s)(uniqueMember=%s))", ldap.EscapeFilter(username), ldap.EscapeFilter(userDN), ldap.EscapeFilter(userDN)), - }) - if err != nil { - return nil, fmt.Errorf("LDAP search failed: %v", err) - } - - for _, e := range result.Entries { - dn, err := ldap.ParseDN(e.DN) - if err != nil || len(dn.RDNs) == 0 { - continue - } - for _, rdn := range dn.RDNs { - for _, rdnTypeAndValue := range rdn.Attributes { - if strings.EqualFold(rdnTypeAndValue.Type, "CN") { - ldapMap[rdnTypeAndValue.Value] = true - } - } - } - } - } - - ldapGroups := make([]string, len(ldapMap)) + ldapGroups := make([]string, 0, len(ldapMap)) for key, _ := range ldapMap { ldapGroups = append(ldapGroups, key) } + return ldapGroups, nil } diff --git a/builtin/credential/ldap/backend_test.go b/builtin/credential/ldap/backend_test.go index 5323c8eea..bd9a2ee76 100644 --- a/builtin/credential/ldap/backend_test.go +++ b/builtin/credential/ldap/backend_test.go @@ -3,6 +3,7 @@ package ldap import ( "fmt" "reflect" + "sort" "testing" "time" @@ -11,6 +12,21 @@ import ( "github.com/mitchellh/mapstructure" ) +/* + * Acceptance test for LDAP Auth Backend + * + * The tests here rely on a public LDAP server: + * [http://www.forumsys.com/tutorials/integration-how-to/ldap/online-ldap-test-server/] + * + * ...as well as existence of a person object, `uid=tesla,dc=example,dc=com`, + * which is a member of a group, `ou=scientists,dc=example,dc=com` + * + * Querying the server from the command line: + * $ ldapsearch -x -H ldap://ldap.forumsys.com -b dc=example,dc=com -s sub \ + * '(&(objectClass=groupOfUniqueNames)(uniqueMember=uid=tesla,dc=example,dc=com))' + * + * $ ldapsearch -x -H ldap://ldap.forumsys.com -b dc=example,dc=com -s sub uid=tesla + */ func factory(t *testing.T) logical.Backend { defaultLeaseTTLVal := time.Hour * 24 maxLeaseTTLVal := time.Hour * 24 * 30 @@ -35,11 +51,22 @@ func TestBackend_basic(t *testing.T) { Backend: b, Steps: []logicaltest.TestStep{ testAccStepConfigUrl(t), - testAccStepGroup(t, "scientists", "foo"), + // Map Scientists group (from LDAP server) with foo policy + testAccStepGroup(t, "Scientists", "foo"), + + // Map engineers group (local) with bar policy testAccStepGroup(t, "engineers", "bar"), + + // Map tesla user with local engineers group testAccStepUser(t, "tesla", "engineers"), + + // Authenticate testAccStepLogin(t, "tesla", "password"), - testAccStepGroupList(t, []string{"engineers", "scientists"}), + + // Verify both groups mappings can be listed back + testAccStepGroupList(t, []string{"engineers", "Scientists"}), + + // Verify user mapping can be listed back testAccStepUserList(t, []string{"tesla"}), }, }) @@ -53,7 +80,7 @@ func TestBackend_basic_authbind(t *testing.T) { Backend: b, Steps: []logicaltest.TestStep{ testAccStepConfigUrlWithAuthBind(t), - testAccStepGroup(t, "scientists", "foo"), + testAccStepGroup(t, "Scientists", "foo"), testAccStepGroup(t, "engineers", "bar"), testAccStepUser(t, "tesla", "engineers"), testAccStepLogin(t, "tesla", "password"), @@ -69,7 +96,7 @@ func TestBackend_basic_discover(t *testing.T) { Backend: b, Steps: []logicaltest.TestStep{ testAccStepConfigUrlWithDiscover(t), - testAccStepGroup(t, "scientists", "foo"), + testAccStepGroup(t, "Scientists", "foo"), testAccStepGroup(t, "engineers", "bar"), testAccStepUser(t, "tesla", "engineers"), testAccStepLogin(t, "tesla", "password"), @@ -85,7 +112,7 @@ func TestBackend_basic_nogroupdn(t *testing.T) { Backend: b, Steps: []logicaltest.TestStep{ testAccStepConfigUrlNoGroupDN(t), - testAccStepGroup(t, "scientists", "foo"), + testAccStepGroup(t, "Scientists", "foo"), testAccStepGroup(t, "engineers", "bar"), testAccStepUser(t, "tesla", "engineers"), testAccStepLoginNoGroupDN(t, "tesla", "password"), @@ -101,13 +128,60 @@ func TestBackend_groupCrud(t *testing.T) { Backend: b, Steps: []logicaltest.TestStep{ testAccStepGroup(t, "g1", "foo"), - testAccStepReadGroup(t, "g1", "foo"), + testAccStepReadGroup(t, "g1", "default,foo"), testAccStepDeleteGroup(t, "g1"), testAccStepReadGroup(t, "g1", ""), }, }) } +/* + * Test backend configuration defaults are successfully read. + */ +func TestBackend_configDefaultsAfterUpdate(t *testing.T) { + b := factory(t) + + logicaltest.Test(t, logicaltest.TestCase{ + AcceptanceTest: false, + Backend: b, + Steps: []logicaltest.TestStep{ + logicaltest.TestStep{ + Operation: logical.UpdateOperation, + Path: "config", + Data: map[string]interface{}{}, + }, + logicaltest.TestStep{ + Operation: logical.ReadOperation, + Path: "config", + Check: func(resp *logical.Response) error { + if resp == nil { + return fmt.Errorf("bad: %#v", resp) + } + + // Test well-known defaults + cfg := resp.Data + defaultGroupFilter := "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))" + if cfg["groupfilter"] != defaultGroupFilter { + t.Errorf("Default mismatch: groupfilter. Expected: '%s', received :'%s'", defaultGroupFilter, cfg["groupfilter"]) + } + + defaultGroupAttr := "cn" + if cfg["groupattr"] != defaultGroupAttr { + t.Errorf("Default mismatch: groupattr. Expected: '%s', received :'%s'", defaultGroupAttr, cfg["groupattr"]) + } + + defaultUserAttr := "cn" + if cfg["userattr"] != defaultUserAttr { + t.Errorf("Default mismatch: userattr. Expected: '%s', received :'%s'", defaultUserAttr, cfg["userattr"]) + } + + return nil + }, + }, + }, + }) +} + func testAccStepConfigUrl(t *testing.T) logicaltest.TestStep { return logicaltest.TestStep{ Operation: logical.UpdateOperation, @@ -172,6 +246,7 @@ func testAccStepConfigUrlNoGroupDN(t *testing.T) logicaltest.TestStep { } func testAccStepGroup(t *testing.T, group string, policies string) logicaltest.TestStep { + t.Logf("[testAccStepGroup] - Registering group %s, policy %s", group, policies) return logicaltest.TestStep{ Operation: logical.UpdateOperation, Path: "groups/" + group, @@ -285,6 +360,7 @@ func testAccStepLogin(t *testing.T, user string, pass string) logicaltest.TestSt }, Unauthenticated: true, + // Verifies user tesla maps to groups via local group (engineers) as well as remote group (Scientiests) Check: logicaltest.TestCheckAuth([]string{"bar", "default", "foo"}), } } @@ -298,6 +374,7 @@ func testAccStepLoginNoGroupDN(t *testing.T, user string, pass string) logicalte }, Unauthenticated: true, + // Verifies a search without defined GroupDN returns a warnting rather than failing Check: func(resp *logical.Response) error { if len(resp.Warnings()) != 1 { return fmt.Errorf("expected a warning due to no group dn, got: %#v", resp.Warnings()) @@ -334,9 +411,16 @@ func testAccStepGroupList(t *testing.T, groups []string) logicaltest.TestStep { return fmt.Errorf("Got error response: %#v", *resp) } - exp := groups - if !reflect.DeepEqual(exp, resp.Data["keys"].([]string)) { - return fmt.Errorf("expected:\n%#v\ngot:\n%#v\n", exp, resp.Data["keys"]) + expected := make([]string, len(groups)) + copy(expected, groups) + sort.Strings(expected) + + sortedResponse := make([]string, len(resp.Data["keys"].([]string))) + copy(sortedResponse, resp.Data["keys"].([]string)) + sort.Strings(sortedResponse) + + if !reflect.DeepEqual(expected, sortedResponse) { + return fmt.Errorf("expected:\n%#v\ngot:\n%#v\n", expected, sortedResponse) } return nil }, @@ -352,9 +436,16 @@ func testAccStepUserList(t *testing.T, users []string) logicaltest.TestStep { return fmt.Errorf("Got error response: %#v", *resp) } - exp := users - if !reflect.DeepEqual(exp, resp.Data["keys"].([]string)) { - return fmt.Errorf("expected:\n%#v\ngot:\n%#v\n", exp, resp.Data["keys"]) + expected := make([]string, len(users)) + copy(expected, users) + sort.Strings(expected) + + sortedResponse := make([]string, len(resp.Data["keys"].([]string))) + copy(sortedResponse, resp.Data["keys"].([]string)) + sort.Strings(sortedResponse) + + if !reflect.DeepEqual(expected, sortedResponse) { + return fmt.Errorf("expected:\n%#v\ngot:\n%#v\n", expected, sortedResponse) } return nil }, diff --git a/builtin/credential/ldap/path_config.go b/builtin/credential/ldap/path_config.go index e79a095e8..f1b455a0e 100644 --- a/builtin/credential/ldap/path_config.go +++ b/builtin/credential/ldap/path_config.go @@ -7,6 +7,7 @@ import ( "net" "net/url" "strings" + "text/template" "github.com/fatih/structs" "github.com/go-ldap/ldap" @@ -21,6 +22,7 @@ func pathConfig(b *backend) *framework.Path { Fields: map[string]*framework.FieldSchema{ "url": &framework.FieldSchema{ Type: framework.TypeString, + Default: "ldap://127.0.0.1", Description: "ldap URL to connect to (default: ldap://127.0.0.1)", }, @@ -41,7 +43,25 @@ func pathConfig(b *backend) *framework.Path { "groupdn": &framework.FieldSchema{ Type: framework.TypeString, - Description: "LDAP domain to use for groups (eg: ou=Groups,dc=example,dc=org)", + Description: "LDAP search base to use for group membership search (eg: ou=Groups,dc=example,dc=org)", + }, + + "groupfilter": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))", + Description: `Go template for querying group membership of user (optional) +The template can access the following context variables: UserDN, Username +Example: (&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}})) +Default: (|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))`, + }, + + "groupattr": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "cn", + Description: `LDAP attribute to follow on objects returned by +in order to enumerate user group membership. +Examples: "cn" or "memberOf", etc. +Default: cn`, }, "upndomain": &framework.FieldSchema{ @@ -51,6 +71,7 @@ func pathConfig(b *backend) *framework.Path { "userattr": &framework.FieldSchema{ Type: framework.TypeString, + Default: "cn", Description: "Attribute used for users (default: cn)", }, @@ -91,20 +112,39 @@ func pathConfig(b *backend) *framework.Path { } } +/* + * Construct ConfigEntry struct using stored configuration. + */ func (b *backend) Config(req *logical.Request) (*ConfigEntry, error) { - entry, err := req.Storage.Get("config") + // Schema for ConfigEntry + fd, err := b.getConfigFieldData() if err != nil { return nil, err } - if entry == nil { - return nil, nil - } - var result ConfigEntry - result.SetDefaults() - if err := entry.DecodeJSON(&result); err != nil { + + // Create a new ConfigEntry, filling in defaults where appropriate + result, err := b.newConfigEntry(fd) + if err != nil { return nil, err } - return &result, nil + + storedConfig, err := req.Storage.Get("config") + if err != nil { + return nil, err + } + + if storedConfig == nil { + // No user overrides, return default configuration + return result, nil + } + + // Deserialize stored configuration. + // Fields not specified in storedConfig will retain their defaults. + if err := storedConfig.DecodeJSON(&result); err != nil { + return nil, err + } + + return result, nil } func (b *backend) pathConfigRead( @@ -123,10 +163,13 @@ func (b *backend) pathConfigRead( }, nil } -func (b *backend) pathConfigWrite( - req *logical.Request, d *framework.FieldData) (*logical.Response, error) { +/* + * Creates and initializes a ConfigEntry object with its default values, + * as specified by the passed schema. + */ +func (b *backend) newConfigEntry(d *framework.FieldData) (*ConfigEntry, error) { + cfg := new(ConfigEntry) - cfg := &ConfigEntry{} url := d.Get("url").(string) if url != "" { cfg.Url = strings.ToLower(url) @@ -143,8 +186,22 @@ func (b *backend) pathConfigWrite( if groupdn != "" { cfg.GroupDN = groupdn } + groupfilter := d.Get("groupfilter").(string) + if groupfilter != "" { + // Validate the template before proceeding + _, err := template.New("queryTemplate").Parse(groupfilter) + if err != nil { + return nil, fmt.Errorf("invalid groupfilter (%v)", err) + } + + cfg.GroupFilter = groupfilter + } + groupattr := d.Get("groupattr").(string) + if groupattr != "" { + cfg.GroupAttr = groupattr + } upndomain := d.Get("upndomain").(string) - if groupdn != "" { + if upndomain != "" { cfg.UPNDomain = upndomain } certificate := d.Get("certificate").(string) @@ -157,13 +214,13 @@ func (b *backend) pathConfigWrite( } cfg.TLSMinVersion = d.Get("tls_min_version").(string) if cfg.TLSMinVersion == "" { - return logical.ErrorResponse("failed to get 'tls_min_version' value"), nil + return nil, fmt.Errorf("failed to get 'tls_min_version' value") } var ok bool _, ok = tlsutil.TLSLookup[cfg.TLSMinVersion] if !ok { - return logical.ErrorResponse("invalid 'tls_min_version'"), nil + return nil, fmt.Errorf("invalid 'tls_min_version'") } startTLS := d.Get("starttls").(bool) @@ -183,17 +240,17 @@ func (b *backend) pathConfigWrite( cfg.DiscoverDN = discoverDN } - // Try to connect to the LDAP server, to validate the URL configuration - // We can also check the URL at this stage, as anything else would probably - // require authentication. - conn, cerr := cfg.DialLDAP() - if cerr != nil { - return logical.ErrorResponse(cerr.Error()), nil + return cfg, nil +} + +func (b *backend) pathConfigWrite( + req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + + // Build a ConfigEntry struct out of the supplied FieldData + cfg, err := b.newConfigEntry(d) + if err != nil { + return logical.ErrorResponse(err.Error()), nil } - if conn == nil { - return logical.ErrorResponse("invalid connection returned from LDAP dial"), nil - } - conn.Close() entry, err := logical.StorageEntryJSON("config", cfg) if err != nil { @@ -210,6 +267,8 @@ type ConfigEntry struct { Url string `json:"url" structs:"url" mapstructure:"url"` UserDN string `json:"userdn" structs:"userdn" mapstructure:"userdn"` GroupDN string `json:"groupdn" structs:"groupdn" mapstructure:"groupdn"` + GroupFilter string `json:"groupfilter" structs:"groupfilter" mapstructure:"groupfilter"` + GroupAttr string `json:"groupattr" structs:"groupattr" mapstructure:"groupattr"` UPNDomain string `json:"upndomain" structs:"upndomain" mapstructure:"upndomain"` UserAttr string `json:"userattr" structs:"userattr" mapstructure:"userattr"` Certificate string `json:"certificate" structs:"certificate" mapstructure:"certificate"` @@ -293,9 +352,24 @@ func (c *ConfigEntry) DialLDAP() (*ldap.Conn, error) { return conn, nil } -func (c *ConfigEntry) SetDefaults() { - c.Url = "ldap://127.0.0.1" - c.UserAttr = "cn" +/* + * Returns FieldData describing our ConfigEntry struct schema + */ +func (b *backend) getConfigFieldData() (*framework.FieldData, error) { + configPath := b.Route("config") + + if configPath == nil { + return nil, logical.ErrUnsupportedPath + } + + raw := make(map[string]interface{}, len(configPath.Fields)) + + fd := framework.FieldData{ + Raw: raw, + Schema: configPath.Fields, + } + + return &fd, nil } const pathConfigHelpSyn = ` diff --git a/website/source/docs/auth/ldap.html.md b/website/source/docs/auth/ldap.html.md index 67a21c463..5fc735e09 100644 --- a/website/source/docs/auth/ldap.html.md +++ b/website/source/docs/auth/ldap.html.md @@ -102,40 +102,95 @@ ldap/ ldap token/ token token based credentials ``` -To use the "ldap" auth backend, an operator must configure it with -the address of the LDAP server that is to be used. An example is shown below. +To use the ldap auth backend, it must first be configured with connection +details for your LDAP server, information on how to authenticate users, and +instructions on how to query for group membership. +The configuration options are categorized and detailed below. + +Configuration is written to `auth/ldap/config`. + +### Connection parameters + +* `url` (string, required) - The LDAP server to connect to. Examples: `ldap://ldap.myorg.com`, `ldaps://ldap.myorg.com:636` +* `starttls` (bool, optional) - If true, issues a `StartTLS` command after establishing an unencrypted connection. +* `insecure_tls` - (bool, optional) - If true, skips LDAP server SSL certificate verification - insecure, use with caution! +* `certificate` - (string, optional) - CA certificate to use when verifying LDAP server certificate, must be x509 PEM encoded. + +### Binding parameters + +There are two alternate methods of resolving the user object used to authenticate the end user: _Search_ or _User Principal Name_. When using _Search_, the bind can be either anonymous or authenticated. User Principal Name is method of specifying users supported by Active Directory. More information on UPN can be found [here](https://msdn.microsoft.com/en-us/library/ms677605(v=vs.85).aspx#userPrincipalName). + +#### Binding - Authenticated Search + +* `binddn` (string, optional) - Distinguished name of object to bind when performing user search. Example: `cn=vault,ou=Users,dc=example,dc=com` +* `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` + +#### 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` + +#### Binding - User Principal Name (AD) + +* `upndomain` (string, optional) - userPrincipalDomain used to construct the UPN 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`. + +### Group Membership Resolution + +Once a user has been authenticated, the LDAP auth backend must know how to resolve which groups the user is a member of. The configuration for this can vary depending on your LDAP server and your directory schema. There are two main strategies when resolving group membership - the first is searching for the authenticated user object and following an attribute to groups it is a member of. The second is to search for group objects of which the authenticated user is a member of. Both methods are supported. + +* `groupfilter` (string, optional) - Go template used when constructing the group membership query. The template can access the following context variables: \[`UserDN`, `Username`\]. The default is `(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))`, which is compatible with several common directory schemas. To support nested group resolution for Active Directory, instead use the following query: `(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}}))`. +* `groupdn` (string, required) - LDAP search base to use for group membership search. This can be the root containing either groups or users. Example: `ou=Groups,dc=example,dc=com` +* `groupattr` (string, optional) - LDAP attribute to follow on objects returned by `groupfilter` in order to enumerate user group membership. Examples: for groupfilter queries returning _group_ objects, use: `cn`. For queries returning _user_ objects, use: `memberOf`. The default is `cn`. + + Use `vault path-help` for more details. +## Examples: + +### Scenario 1 + +* LDAP server running on `ldap.example.com`, port 389. +* Server supports `STARTTLS` command to initiate encryption on the standard port. +* CA Certificate stored in file named `ldap_ca_cert.pem` +* Server is Active Directory supporting the userPrincipalName attribute. Users are identified as `username@example.com`. +* Groups are nested, we will use `LDAP_MATCHING_RULE_IN_CHAIN` to walk the ancestry graph. +* Group search will start under `ou=Groups,dc=example,dc=com`. For all group objects under that path, the `member` attribute will be checked for a match against the authenticated user. +* Group names are identified using their `cn` attribute. + ``` -$ vault write auth/ldap/config url="ldap://ldap.forumsys.com" \ - userattr=uid \ - userdn="dc=example,dc=com" \ - groupdn="dc=example,dc=com" \ - upndomain="forumsys.com" \ +$ vault write auth/ldap/config \ + url="ldap://ldap.example.com" \ + groupdn="ou=Groups,dc=example,dc=com" \ + groupfilter="(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}}))" \ + groupattr="cn" \ + upndomain="example.com" \ certificate=@ldap_ca_cert.pem \ insecure_tls=false \ starttls=true ... ``` -The above configures the target LDAP server, along with the parameters -specifying how users and groups should be queried from the LDAP server. +### Scenario 2 + +* LDAP server running on `ldap.example.com`, port 389. +* Server supports `STARTTLS` command to initiate encryption on the standard port. +* CA Certificate stored in file named `ldap_ca_cert.pem` +* Server does not allow anonymous binds for performing user search. +* Bind account used for searching is `cn=vault,ou=users,dc=example,dc=com` with password `My$ecrt3tP4ss`. +* User objects are under the `ou=Users,dc-example,dc=com` organizational unit. +* Username passed to vault when authenticating maps to the `sAMAccountName` attribute. +* Group membership will be resolved via the `memberOf` attribute of _user_ objects. That search will begin under `ou=Users,dc=example,dc=com`. -If your users are not located directly below the "userdn", e.g. in several -OUs like ``` - ou=users,dc=example,dc=com -ou=people ou=external ou=robots -``` -you can also specify a `binddn` and `bindpass` for vault to search for the DN -of a user. This also works for the AD where a typical setup is to have user -DNs in the form `cn=Firstname Lastname,ou=Users,dc=example,dc=com` but you -want to login users using the `sAMAccountName` attribute. For that specify -``` -$ vault write auth/ldap/config url="ldap://ldap.forumsys.com" \ +$ vault write auth/ldap/config url="ldap://ldap.example.com" \ userattr=sAMAccountName \ - userdn="ou=users,dc=example,dc=com" \ - groupdn="dc=example,dc=com" \ + userdn="ou=Users,dc=example,dc=com" \ + groupdn="ou=Users,dc=example,dc=com" \ + groupfilter="(&(objectClass=person)(uid={{.Username}}))" \ + groupattr="memberOf" \ binddn="cn=vault,ou=users,dc=example,dc=com" \ bindpass='My$ecrt3tP4ss' \ certificate=@ldap_ca_cert.pem \ @@ -143,8 +198,30 @@ $ vault write auth/ldap/config url="ldap://ldap.forumsys.com" \ starttls=true ... ``` -To discover the bind dn for a user with an anonymous bind, use the `discoverdn=true` -parameter and leave the `binddn` / `bindpass` empty. + +### Scenario 3 + +* LDAP server running on `ldap.example.com`, port 636 (LDAPS) +* CA Certificate stored in file named `ldap_ca_cert.pem` +* User objects are under the `ou=Users,dc=example,dc=com` organizational unit. +* Username passed to vault when authenticating maps to the `uid` attribute. +* User bind DN will be auto-discovered using anonymous binding. +* Group membership will be resolved via any one of `memberUid`, `member`, or `uniqueMember` attributes. That search will begin under `ou=Groups,dc=example,dc=com`. +* Group names are identified using the `cn` attribute. + +``` +$ vault write auth/ldap/config url="ldaps://ldap.example.com" \ + userattr="uid" \ + userdn="ou=Users,dc=example,dc=com" \ + discoverdn=true \ + groupdn="ou=Groups,dc=example,dc=com" \ + certificate=@ldap_ca_cert.pem \ + insecure_tls=false \ + starttls=true +... +``` + +## LDAP Group -> Policy Mapping Next we want to create a mapping from an LDAP group to a Vault policy: @@ -175,3 +252,6 @@ with this token are listed below: bar, foo, foobar ``` +## Note on policy mapping + +It should be noted that user -> policy mapping (via group membership) happens at token creation time. And changes in group membership on the LDAP server will not affect tokens that have already been provisioned. To see these changes, old tokens should be revoked and the user should be asked to reauthenticate.