// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package ldap import ( "context" "encoding/json" "encoding/pem" "fmt" "strings" "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/oplog" "github.com/hashicorp/cap/ldap" ) const ( DefaultEmailAttribute = "email" DefaultFullNameAttribute = "fullName" DefaultRequestTimeout = 5 // seconds ) // Authenticate authenticates loginName and password via the auth method's // configured LDAP service. The account for the loginName is returned if // authentication is successful. Returns nil if authentication fails. // // If the AuthMethod.EnableGroups is true, then the authenticated user's groups // will be returned in account. // // Authenticate will update the stored values for the authenticated user's // Account: FullName, Email, Dn, EntryAttributes, and MemberOfGroups. // // Note: the auth_method table uses public id as its PK, so there's no need a // scope id parameter. func (r *Repository) Authenticate(ctx context.Context, authMethodId, loginName, password string) (*Account, error) { const op = "ldap.(Repository).Authenticate" switch { case authMethodId == "": return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method id", errors.WithoutEvent()) case loginName == "": return nil, errors.New(ctx, errors.InvalidParameter, op, "missing login name", errors.WithoutEvent()) case password == "": return nil, errors.New(ctx, errors.InvalidParameter, op, "missing password", errors.WithoutEvent()) } // lookup auth method am, err := r.lookupAuthMethod(ctx, authMethodId) if err != nil { return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to lookup auth method id: %q", authMethodId)) } if am == nil { return nil, errors.New(ctx, errors.RecordNotFound, op, fmt.Sprintf("auth method id %q not found", authMethodId)) } var clientTLSKeyPem string if am.ClientCertificateKey != nil { clientTLSKeyPem = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: am.ClientCertificateKey})) } // config cap ldap provider client, err := ldap.NewClient(ctx, &ldap.ClientConfig{ IncludeUserAttributes: true, StartTLS: am.StartTls, InsecureTLS: am.InsecureTls, DiscoverDN: am.DiscoverDn, AnonymousGroupSearch: am.AnonGroupSearch, UPNDomain: am.UpnDomain, URLs: am.Urls, UserDN: am.UserDn, UserFilter: am.UserFilter, UserAttr: am.UserAttr, IncludeUserGroups: am.EnableGroups, UseTokenGroups: am.UseTokenGroups, GroupDN: am.GroupDn, GroupAttr: am.GroupAttr, GroupFilter: am.GroupFilter, Certificates: am.Certificates, ClientTLSKey: clientTLSKeyPem, ClientTLSCert: am.ClientCertificate, BindDN: am.BindDn, BindPassword: am.BindPassword, RequestTimeout: DefaultRequestTimeout, DerefAliases: am.DereferenceAliases, MaximumPageSize: int(am.MaximumPageSize), }) if err != nil { return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to initialize ldap client with auth method retrieved from database")) } defer client.Close(ctx) // authen user authResult, err := client.Authenticate(ctx, loginName, password) if err != nil { return nil, errors.Wrap(ctx, err, op, errors.WithMsg("authenticate failed")) } acct, err := NewAccount(ctx, am.ScopeId, am.PublicId, loginName) if err != nil { return nil, errors.Wrap(ctx, err, op) } acctId, err := newAccountId(ctx, authMethodId, loginName) if err != nil { return nil, errors.Wrap(ctx, err, op) } acct.PublicId = acctId acct.Dn = authResult.UserDN if authResult.UserAttributes != nil { emailAttr := DefaultEmailAttribute fullNameAttr := DefaultFullNameAttribute attrMaps, err := am.convertAccountAttributeMaps(ctx) if err != nil { return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to convert account attribute maps")) } for _, attrMap := range attrMaps { switch attrMap.ToAttribute { case DefaultEmailAttribute: emailAttr = attrMap.FromAttribute case DefaultFullNameAttribute: fullNameAttr = attrMap.FromAttribute default: return nil, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("invalid to attribute %q", attrMap.ToAttribute)) } } found, email := caseInsensitiveAttributeSearch(emailAttr, authResult.UserAttributes) if found { acct.Email = email[0] } found, fullName := caseInsensitiveAttributeSearch(fullNameAttr, authResult.UserAttributes) if found { acct.FullName = fullName[0] } } if len(authResult.Groups) > 0 { encodedGroups, err := json.Marshal(authResult.Groups) if err != nil { return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to encode user groups")) } acct.MemberOfGroups = string(encodedGroups) } oplogWrapper, err := r.kms.GetWrapper(ctx, am.ScopeId, kms.KeyPurposeOplog) if err != nil { return nil, errors.Wrap(ctx, err, op) } md, err := acct.oplog(ctx, oplog.OpType_OP_TYPE_CREATE) if err != nil { return nil, errors.Wrap(ctx, err, op) } // upsert account if err := r.writer.Create( ctx, acct, db.WithOnConflict(&db.OnConflict{ Target: db.Columns{"public_id"}, // id is predictable and uses both auth method id and login name for inputs Action: db.SetColumns([]string{"full_name", "email", "dn", "member_of_groups"}), }), db.WithOplog(oplogWrapper, md), ); err != nil { return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to create/update ldap account")) } // return account return acct, nil } func caseInsensitiveAttributeSearch(attrName string, attributes map[string][]string) (bool, []string) { for k, v := range attributes { if strings.EqualFold(k, attrName) { return true, v } } return false, nil }