+82
-36
@@ -32,9 +32,12 @@ type LDAPConfig struct {
|
||||
}
|
||||
|
||||
type LDAPIdentity struct {
|
||||
Username string
|
||||
UserDN string
|
||||
Groups []string
|
||||
Username string
|
||||
UserDN string
|
||||
Groups []string
|
||||
BindDuration time.Duration
|
||||
UserLookupDuration time.Duration
|
||||
GroupMembershipLookupDuration time.Duration
|
||||
// Diagnostics contains non-sensitive LDAP processing notes useful for debugging auth decisions.
|
||||
Diagnostics []string
|
||||
}
|
||||
@@ -79,13 +82,14 @@ func NewLDAPAuthenticator(cfg LDAPConfig) (*LDAPAuthenticator, error) {
|
||||
}
|
||||
|
||||
func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, username string, password string) (LDAPIdentity, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" || password == "" {
|
||||
inputUsername := strings.TrimSpace(username)
|
||||
if inputUsername == "" || password == "" {
|
||||
return LDAPIdentity{}, ErrLDAPInvalidCredentials
|
||||
}
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return LDAPIdentity{}, err
|
||||
}
|
||||
bindUsername, rewrittenToUPN := normalizeBindUsername(inputUsername, a.baseDN)
|
||||
|
||||
conn, err := a.connect()
|
||||
if err != nil {
|
||||
@@ -93,19 +97,27 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.Bind(username, password); err != nil {
|
||||
bindStartedAt := time.Now()
|
||||
err = conn.Bind(bindUsername, password)
|
||||
bindDuration := time.Since(bindStartedAt)
|
||||
if err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
|
||||
return LDAPIdentity{}, fmt.Errorf("%w: ldap bind rejected credentials", ErrLDAPInvalidCredentials)
|
||||
return LDAPIdentity{}, fmt.Errorf("%w: ldap bind rejected credentials (bind_duration=%s)", ErrLDAPInvalidCredentials, bindDuration)
|
||||
}
|
||||
return LDAPIdentity{}, fmt.Errorf("%w: bind failed: %v", ErrLDAPOperationFailed, err)
|
||||
return LDAPIdentity{}, fmt.Errorf("%w: bind failed: %v (bind_duration=%s)", ErrLDAPOperationFailed, err, bindDuration)
|
||||
}
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return LDAPIdentity{}, err
|
||||
}
|
||||
|
||||
identity := LDAPIdentity{
|
||||
Username: username,
|
||||
UserDN: username,
|
||||
Username: inputUsername,
|
||||
UserDN: bindUsername,
|
||||
BindDuration: bindDuration,
|
||||
}
|
||||
identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("bind_duration_ms=%d", bindDuration.Milliseconds()))
|
||||
if rewrittenToUPN {
|
||||
identity.Diagnostics = append(identity.Diagnostics, "bind_username_rewritten_to_upn")
|
||||
}
|
||||
if whoami, err := conn.WhoAmI(nil); err != nil {
|
||||
identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("whoami_failed:%v", err))
|
||||
@@ -118,9 +130,12 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user
|
||||
identity.Diagnostics = append(identity.Diagnostics, "whoami_non_dn_authzid")
|
||||
}
|
||||
|
||||
entry, lookupStrategy, err := a.lookupUserEntry(conn, username, identity.UserDN)
|
||||
userLookupStartedAt := time.Now()
|
||||
entry, lookupStrategy, err := a.lookupUserEntry(conn, bindUsername, identity.UserDN)
|
||||
identity.UserLookupDuration = time.Since(userLookupStartedAt)
|
||||
identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("user_lookup_duration_ms=%d", identity.UserLookupDuration.Milliseconds()))
|
||||
if err != nil {
|
||||
return LDAPIdentity{}, err
|
||||
return LDAPIdentity{}, fmt.Errorf("%w: %v (bind_duration=%s user_lookup_duration=%s)", ErrLDAPOperationFailed, err, identity.BindDuration, identity.UserLookupDuration)
|
||||
}
|
||||
if entry != nil {
|
||||
if lookupStrategy == "" {
|
||||
@@ -143,6 +158,7 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user
|
||||
}
|
||||
|
||||
groupSet := make(map[string]struct{})
|
||||
groupLookupStartedAt := time.Now()
|
||||
if entry != nil {
|
||||
for _, groupDN := range entry.GetAttributeValues("memberOf") {
|
||||
groupDN = strings.TrimSpace(groupDN)
|
||||
@@ -153,30 +169,11 @@ func (a *LDAPAuthenticator) AuthenticateAndFetchGroups(ctx context.Context, user
|
||||
}
|
||||
}
|
||||
|
||||
groupFilter := buildGroupMembershipFilter(identity.UserDN, principalCandidates(username))
|
||||
groupEntries, err := conn.Search(ldap.NewSearchRequest(
|
||||
a.baseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
groupFilter,
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
))
|
||||
if err == nil {
|
||||
for _, e := range groupEntries.Entries {
|
||||
if dn := strings.TrimSpace(e.DN); dn != "" {
|
||||
groupSet[dn] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(groupEntries.Entries) == 0 {
|
||||
identity.Diagnostics = append(identity.Diagnostics, "group_search_returned_no_entries")
|
||||
}
|
||||
} else {
|
||||
identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("group_search_failed:%v", err))
|
||||
}
|
||||
// Intentionally skip subtree group membership search for now.
|
||||
// Authorization is based only on direct group membership values present in the user entry (memberOf).
|
||||
identity.GroupMembershipLookupDuration = time.Since(groupLookupStartedAt)
|
||||
identity.Diagnostics = append(identity.Diagnostics, fmt.Sprintf("group_lookup_duration_ms=%d", identity.GroupMembershipLookupDuration.Milliseconds()))
|
||||
identity.Diagnostics = append(identity.Diagnostics, "group_search_skipped_direct_memberof_only")
|
||||
|
||||
identity.Groups = mapKeysSorted(groupSet)
|
||||
identity.Diagnostics = compactTrimmedStrings(identity.Diagnostics)
|
||||
@@ -399,6 +396,55 @@ func parseWhoAmIDN(authzID string) string {
|
||||
return authzID
|
||||
}
|
||||
|
||||
func normalizeBindUsername(username string, baseDN string) (string, bool) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return "", false
|
||||
}
|
||||
if looksLikeDN(username) || strings.Contains(username, "@") {
|
||||
return username, false
|
||||
}
|
||||
|
||||
// Convert DOMAIN\user to user before UPN rewrite.
|
||||
if idx := strings.LastIndex(username, `\`); idx >= 0 && idx < len(username)-1 {
|
||||
username = strings.TrimSpace(username[idx+1:])
|
||||
}
|
||||
|
||||
domain := upnDomainFromBaseDN(baseDN)
|
||||
if domain == "" {
|
||||
return username, false
|
||||
}
|
||||
if strings.Contains(username, "@") {
|
||||
return username, false
|
||||
}
|
||||
return username + "@" + domain, true
|
||||
}
|
||||
|
||||
func upnDomainFromBaseDN(baseDN string) string {
|
||||
baseDN = strings.TrimSpace(baseDN)
|
||||
if baseDN == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(baseDN, ",")
|
||||
labels := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if len(part) < 3 || !strings.EqualFold(part[:3], "dc=") {
|
||||
continue
|
||||
}
|
||||
label := strings.TrimSpace(part[3:])
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
labels = append(labels, label)
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
func principalCandidates(username string) []string {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
|
||||
Reference in New Issue
Block a user