diff --git a/internal/handler/auth.go b/internal/handler/auth.go index a0e5048..49efd4c 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -1135,27 +1135,17 @@ func (h *Handler) handleNegotiate(w http.ResponseWriter, r *http.Request) { ip := getClientIP(r) - // Look up user by kerberos identity mapping - userGUID, err := h.store.ResolveMapping("kerberos", cname) - if err != nil { - // Try without realm - userGUID, err = h.store.ResolveMapping("kerberos", username) + userGUID, ldapGroups, err := h.resolveNegotiateUser(username, cname) + if errors.Is(err, auth.ErrLDAPAccountDisabled) { + jsonError(w, "account disabled", http.StatusForbidden) + return } if err != nil { - // Auto-provision: look for a directory user with matching display name/email. - users, _ := h.store.ListUsers() - if guid := matchAutoProvisionUser(users, username); guid != "" { - userGUID = guid - // Create identity mapping for next time - h.store.SetIdentityMapping("kerberos", cname, userGUID) - } - if userGUID == "" { - h.audit("negotiate_failed", "", ip, map[string]interface{}{ - "principal": cname, "reason": "no matching user", - }) - jsonError(w, "no user found for Kerberos principal: "+cname, http.StatusUnauthorized) - return - } + h.audit("negotiate_failed", "", ip, map[string]interface{}{ + "principal": cname, "reason": "no matching user", + }) + jsonError(w, "no user found for Kerberos principal: "+cname, http.StatusUnauthorized) + return } user, err := h.store.ResolveUser(userGUID) @@ -1182,7 +1172,7 @@ func (h *Handler) handleNegotiate(w http.ResponseWriter, r *http.Request) { jsonError(w, "access denied: not assigned to this app", http.StatusForbidden) return } - accessToken, refreshToken, expiresIn, err := h.issueTokenPair(user, roles, perms, nil, app) + accessToken, refreshToken, expiresIn, err := h.issueTokenPair(user, roles, perms, ldapGroups, app) if err != nil { jsonError(w, "token generation failed", http.StatusInternalServerError) return @@ -1634,6 +1624,36 @@ func (h *Handler) resolveKerberosUser(username string) (string, []string, error) return newUser.GUID, result.Groups, nil } +// resolveNegotiateUser maps a verified Kerberos principal to a user for +// GET /api/auth/negotiate. An explicit "kerberos" identity mapping wins; otherwise +// the directory is consulted exactly as /login/sso does (resolveKerberosUser), so a +// directory user who signed in with a password — or never signed in — is found by +// sAMAccountName. The display-name/email match is kept only as the fallback for +// deployments without a directory. A disabled directory account is returned as +// auth.ErrLDAPAccountDisabled and never falls through to that match. +func (h *Handler) resolveNegotiateUser(username, cname string) (string, []string, error) { + for _, principal := range []string{cname, username} { + if guid, err := h.store.ResolveMapping("kerberos", principal); err == nil { + return guid, nil, nil + } + } + + guid, groups, err := h.resolveKerberosUser(username) + if err == nil { + return guid, groups, nil + } + if errors.Is(err, auth.ErrLDAPAccountDisabled) { + return "", nil, err + } + + users, _ := h.store.ListUsers() + if guid := matchAutoProvisionUser(users, username); guid != "" { + h.store.SetIdentityMapping("kerberos", cname, guid) + return guid, nil, nil + } + return "", nil, fmt.Errorf("no user for Kerberos principal %q: %w", cname, err) +} + // handleNegotiateTestForm handles the fallback login form (POST). func (h *Handler) handleNegotiateTestForm(w http.ResponseWriter, r *http.Request) { if !h.loginLimiter.allow(getClientIP(r)) { diff --git a/internal/handler/negotiate_resolve_test.go b/internal/handler/negotiate_resolve_test.go new file mode 100644 index 0000000..bcd03fd --- /dev/null +++ b/internal/handler/negotiate_resolve_test.go @@ -0,0 +1,78 @@ +package handler + +import ( + "testing" + "time" + + "simpleauth/internal/store" +) + +// These cover the non-directory branches of resolveNegotiateUser. The directory +// branch delegates to resolveKerberosUser (shared with /login/sso) and needs a +// live LDAP server, so it is exercised end to end against a Samba AD DC rather +// than here. + +func createNegotiateTestUser(t *testing.T, s store.Store, u *store.User) { + t.Helper() + u.CreatedAt = time.Now().UTC() + if err := s.CreateUser(u); err != nil { + t.Fatalf("create user: %v", err) + } +} + +func TestResolveNegotiateUser_ExplicitKerberosMappingWins(t *testing.T) { + h, s := testSetup(t) + createNegotiateTestUser(t, s, &store.User{GUID: "mapped", DisplayName: "John Doe"}) + if err := s.SetIdentityMapping("kerberos", "jdoe@GREEN.ORG", "mapped"); err != nil { + t.Fatalf("map: %v", err) + } + + guid, _, err := h.resolveNegotiateUser("jdoe", "jdoe@GREEN.ORG") + if err != nil || guid != "mapped" { + t.Fatalf("explicit kerberos mapping must resolve: guid=%q err=%v", guid, err) + } +} + +func TestResolveNegotiateUser_RealmlessMappingResolves(t *testing.T) { + h, s := testSetup(t) + createNegotiateTestUser(t, s, &store.User{GUID: "mapped", DisplayName: "John Doe"}) + if err := s.SetIdentityMapping("kerberos", "jdoe", "mapped"); err != nil { + t.Fatalf("map: %v", err) + } + + guid, _, err := h.resolveNegotiateUser("jdoe", "jdoe@GREEN.ORG") + if err != nil || guid != "mapped" { + t.Fatalf("realm-less kerberos mapping must resolve: guid=%q err=%v", guid, err) + } +} + +func TestResolveNegotiateUser_WithoutDirectoryKeepsLegacyMatch(t *testing.T) { + h, s := testSetup(t) + createNegotiateTestUser(t, s, &store.User{GUID: "legacy", DisplayName: "jdoe"}) + + guid, _, err := h.resolveNegotiateUser("jdoe", "jdoe@GREEN.ORG") + if err != nil || guid != "legacy" { + t.Fatalf("without a directory the display-name match must still resolve: guid=%q err=%v", guid, err) + } + if mapped, err := s.ResolveMapping("kerberos", "jdoe@GREEN.ORG"); err != nil || mapped != "legacy" { + t.Fatalf("legacy match must record a kerberos mapping for next time: %q %v", mapped, err) + } +} + +func TestResolveNegotiateUser_UnknownPrincipalFails(t *testing.T) { + h, s := testSetup(t) + createNegotiateTestUser(t, s, &store.User{GUID: "someone", DisplayName: "John Doe", Email: "jdoe@green.org"}) + + if guid, _, err := h.resolveNegotiateUser("jdoe", "jdoe@GREEN.ORG"); err == nil { + t.Fatalf("a principal with no mapping, no directory entry and no exact match must fail, got %q", guid) + } +} + +func TestResolveNegotiateUser_AppLocalUserNeverMatched(t *testing.T) { + h, s := testSetup(t) + createNegotiateTestUser(t, s, &store.User{GUID: "app-owned", OwnerAppID: "shop", DisplayName: "jdoe"}) + + if guid, _, err := h.resolveNegotiateUser("jdoe", "jdoe@GREEN.ORG"); err == nil { + t.Fatalf("an app-local user must never be bound to a Kerberos principal (M12), got %q", guid) + } +}