Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 40 additions & 20 deletions internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
78 changes: 78 additions & 0 deletions internal/handler/negotiate_resolve_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading