mirror of https://github.com/dexidp/dex.git
9 changed files with 1120 additions and 1 deletions
@ -0,0 +1,332 @@
|
||||
// Package ldap implements strategies for authenticating using the LDAP protocol.
|
||||
// This file contains Kerberos/SPNEGO authentication support for LDAP connector.
|
||||
package ldap |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/base64" |
||||
"encoding/json" |
||||
"fmt" |
||||
"log/slog" |
||||
"net/http" |
||||
"os" |
||||
"strings" |
||||
|
||||
"github.com/jcmturner/gofork/encoding/asn1" |
||||
"github.com/jcmturner/gokrb5/v8/credentials" |
||||
"github.com/jcmturner/gokrb5/v8/gssapi" |
||||
"github.com/jcmturner/gokrb5/v8/keytab" |
||||
"github.com/jcmturner/gokrb5/v8/service" |
||||
"github.com/jcmturner/gokrb5/v8/spnego" |
||||
"github.com/jcmturner/gokrb5/v8/types" |
||||
|
||||
"github.com/dexidp/dex/connector" |
||||
"github.com/go-ldap/ldap/v3" |
||||
) |
||||
|
||||
// KerberosValidator abstracts SPNEGO validation for unit-testing.
|
||||
type KerberosValidator interface { |
||||
// ValidateRequest returns (principal, realm, ok, err). ok=false means header missing/invalid.
|
||||
ValidateRequest(r *http.Request) (string, string, bool, error) |
||||
// Challenge writes a 401 Negotiate challenge.
|
||||
Challenge(w http.ResponseWriter) |
||||
// ContinueToken tries to advance SPNEGO handshake and returns a response token to include
|
||||
// in WWW-Authenticate: Negotiate <token>. Returns (nil, false) if no continuation is needed/possible.
|
||||
ContinueToken(r *http.Request) ([]byte, bool) |
||||
} |
||||
|
||||
// writeNegotiateChallenge writes a standard 401 Negotiate challenge.
|
||||
func writeNegotiateChallenge(w http.ResponseWriter) { |
||||
w.Header().Set("WWW-Authenticate", "Negotiate") |
||||
w.WriteHeader(http.StatusUnauthorized) |
||||
} |
||||
|
||||
// mapPrincipal maps a Kerberos principal to LDAP username per configuration.
|
||||
func mapPrincipal(principal, realm, mapping string) string { |
||||
p := principal |
||||
switch strings.ToLower(mapping) { |
||||
case "localpart", "samaccountname": |
||||
if i := strings.IndexByte(principal, '@'); i >= 0 { |
||||
p = principal[:i] |
||||
} |
||||
return strings.ToLower(p) |
||||
case "userprincipalname": |
||||
return strings.ToLower(principal) |
||||
default: |
||||
if i := strings.IndexByte(principal, '@'); i >= 0 { |
||||
p = principal[:i] |
||||
} |
||||
return strings.ToLower(p) |
||||
} |
||||
} |
||||
|
||||
// gokrb5 implementation of KerberosValidator
|
||||
|
||||
// context key used by gokrb5 to store credentials in the context
|
||||
var ctxCredentialsKey interface{} = "github.com/jcmturner/gokrb5/v8/ctxCredentials" |
||||
|
||||
// SPNEGO NegTokenResp (AcceptIncomplete + KRB5 mech) base64 payload used by gokrb5's HTTP server
|
||||
// to prompt the client to continue the handshake.
|
||||
const spnegoIncompleteKRB5B64 = "oRQwEqADCgEBoQsGCSqGSIb3EgECAg==" |
||||
|
||||
type gokrb5Validator struct { |
||||
kt *keytab.Keytab |
||||
logger *slog.Logger |
||||
} |
||||
|
||||
func newGokrb5ValidatorWithLogger(keytabPath string, logger *slog.Logger) (KerberosValidator, error) { |
||||
kt, err := keytab.Load(keytabPath) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to load keytab: %w", err) |
||||
} |
||||
if fi, err := os.Stat(keytabPath); err != nil || fi.IsDir() { |
||||
return nil, fmt.Errorf("invalid keytab path: %s", keytabPath) |
||||
} |
||||
if logger == nil { |
||||
logger = slog.Default() |
||||
} |
||||
return &gokrb5Validator{kt: kt, logger: logger}, nil |
||||
} |
||||
|
||||
func (v *gokrb5Validator) ValidateRequest(r *http.Request) (string, string, bool, error) { |
||||
h := r.Header.Get("Authorization") |
||||
if h == "" || !strings.HasPrefix(h, "Negotiate ") { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: missing or non-negotiate Authorization header", "path", r.URL.Path) |
||||
} |
||||
return "", "", false, nil |
||||
} |
||||
b64 := strings.TrimSpace(h[len("Negotiate "):]) |
||||
if b64 == "" { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: empty negotiate token", "path", r.URL.Path) |
||||
} |
||||
return "", "", false, nil |
||||
} |
||||
data, err := base64.StdEncoding.DecodeString(b64) |
||||
if err != nil { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: invalid base64 in Authorization", "err", err) |
||||
} |
||||
return "", "", false, nil |
||||
} |
||||
var tok spnego.SPNEGOToken |
||||
if err := tok.Unmarshal(data); err != nil { |
||||
// Try raw KRB5 token and wrap
|
||||
var k5 spnego.KRB5Token |
||||
if k5.Unmarshal(data) != nil { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: failed to unmarshal SPNEGO token and not raw KRB5", "err", err) |
||||
} |
||||
return "", "", false, nil |
||||
} |
||||
tok.Init = true |
||||
tok.NegTokenInit = spnego.NegTokenInit{ |
||||
MechTypes: []asn1.ObjectIdentifier{k5.OID}, |
||||
MechTokenBytes: data, |
||||
} |
||||
} |
||||
|
||||
// Pass client address when available (improves AP-REQ validation with address-bound tickets)
|
||||
var sp *spnego.SPNEGO |
||||
if ha, err := types.GetHostAddress(r.RemoteAddr); err == nil { |
||||
sp = spnego.SPNEGOService(v.kt, service.ClientAddress(ha), service.DecodePAC(false)) |
||||
} else { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: cannot parse client address", "remote", r.RemoteAddr, "err", err) |
||||
} |
||||
sp = spnego.SPNEGOService(v.kt, service.DecodePAC(false)) |
||||
} |
||||
authed, ctx, status := sp.AcceptSecContext(&tok) |
||||
if status.Code != gssapi.StatusComplete { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: AcceptSecContext not complete", "code", status.Code, "message", status.Message) |
||||
} |
||||
return "", "", false, nil |
||||
} |
||||
if !authed || ctx == nil { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: not authenticated or no context") |
||||
} |
||||
return "", "", false, nil |
||||
} |
||||
id, _ := ctx.Value(ctxCredentialsKey).(*credentials.Credentials) |
||||
if id == nil { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: credentials missing in context") |
||||
} |
||||
return "", "", false, fmt.Errorf("no credentials in context") |
||||
} |
||||
return id.UserName(), id.Domain(), true, nil |
||||
} |
||||
|
||||
func (v *gokrb5Validator) Challenge(w http.ResponseWriter) { writeNegotiateChallenge(w) } |
||||
|
||||
// ContinueToken attempts to continue the SPNEGO handshake and returns a response token
|
||||
// (to be placed into WWW-Authenticate: Negotiate <b64>) if available.
|
||||
func (v *gokrb5Validator) ContinueToken(r *http.Request) ([]byte, bool) { |
||||
h := r.Header.Get("Authorization") |
||||
if h == "" || !strings.HasPrefix(h, "Negotiate ") { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: ContinueToken without negotiate header", "path", r.URL.Path) |
||||
} |
||||
return nil, false |
||||
} |
||||
b64 := strings.TrimSpace(h[len("Negotiate "):]) |
||||
data, err := base64.StdEncoding.DecodeString(b64) |
||||
if err != nil { |
||||
// Malformed header: ask client to continue with KRB5 mech
|
||||
if tok, e := base64.StdEncoding.DecodeString(spnegoIncompleteKRB5B64); e == nil { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: malformed negotiate token; sending incomplete KRB5 response") |
||||
} |
||||
return tok, true |
||||
} |
||||
return nil, false |
||||
} |
||||
var tok spnego.SPNEGOToken |
||||
if err := tok.Unmarshal(data); err != nil { |
||||
// Not a full SPNEGO token; still ask client to continue
|
||||
if tokb, e := base64.StdEncoding.DecodeString(spnegoIncompleteKRB5B64); e == nil { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: non-SPNEGO token; sending incomplete KRB5 response") |
||||
} |
||||
return tokb, true |
||||
} |
||||
// As a fallback, try wrapping as raw KRB5
|
||||
var k5 spnego.KRB5Token |
||||
if k5.Unmarshal(data) != nil { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: not KRB5 token; cannot continue") |
||||
} |
||||
return nil, false |
||||
} |
||||
tok.Init = true |
||||
tok.NegTokenInit = spnego.NegTokenInit{MechTypes: []asn1.ObjectIdentifier{k5.OID}, MechTokenBytes: data} |
||||
} |
||||
// Try continue with same options as in ValidateRequest
|
||||
var sp *spnego.SPNEGO |
||||
if ha, err := types.GetHostAddress(r.RemoteAddr); err == nil { |
||||
sp = spnego.SPNEGOService(v.kt, service.ClientAddress(ha), service.DecodePAC(false)) |
||||
} else { |
||||
sp = spnego.SPNEGOService(v.kt, service.DecodePAC(false)) |
||||
} |
||||
_, ctx, status := sp.AcceptSecContext(&tok) |
||||
if status.Code != gssapi.StatusContinueNeeded || ctx == nil { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: no continuation required", "code", status.Code, "message", status.Message) |
||||
} |
||||
return nil, false |
||||
} |
||||
// Ask client to continue using standard NegTokenResp (KRB5, incomplete)
|
||||
if tokb, e := base64.StdEncoding.DecodeString(spnegoIncompleteKRB5B64); e == nil { |
||||
if v.logger != nil { |
||||
v.logger.Info("kerberos: continuation needed; sending incomplete KRB5 response") |
||||
} |
||||
return tokb, true |
||||
} |
||||
return nil, false |
||||
} |
||||
|
||||
// LDAP connector SPNEGO integration
|
||||
|
||||
// krbLookupUserHook allows tests to inject a user entry without LDAP queries.
|
||||
var krbLookupUserHook func(c *ldapConnector, username string) (ldap.Entry, bool, error) |
||||
|
||||
// TrySPNEGO attempts Kerberos auth and builds identity on success.
|
||||
func (c *ldapConnector) TrySPNEGO(ctx context.Context, s connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) { |
||||
if !c.krbEnabled || c.krbValidator == nil { |
||||
return nil, false, nil |
||||
} |
||||
|
||||
principal, realm, ok, err := c.krbValidator.ValidateRequest(r) |
||||
if err != nil || !ok { |
||||
if !c.krbConf.FallbackToPassword { |
||||
// Try to get a continuation token to advance SPNEGO handshake
|
||||
if tok, ok2 := c.krbValidator.ContinueToken(r); ok2 && len(tok) > 0 { |
||||
c.logger.Info("kerberos SPNEGO continuation required; sending response token") |
||||
w.Header().Set("WWW-Authenticate", "Negotiate "+base64.StdEncoding.EncodeToString(tok)) |
||||
w.WriteHeader(http.StatusUnauthorized) |
||||
return nil, true, nil |
||||
} |
||||
if err != nil { |
||||
c.logger.Info("kerberos SPNEGO validation error; sending Negotiate challenge", "err", err) |
||||
} else { |
||||
c.logger.Info("kerberos SPNEGO not completed or header missing; sending Negotiate challenge") |
||||
} |
||||
c.krbValidator.Challenge(w) |
||||
return nil, true, nil |
||||
} |
||||
c.logger.Info("kerberos SPNEGO fallback to password enabled; rendering login form") |
||||
return nil, false, nil |
||||
} |
||||
|
||||
if c.krbConf.ExpectedRealm != "" && !strings.EqualFold(c.krbConf.ExpectedRealm, realm) { |
||||
c.logger.Info("kerberos realm mismatch", "expected", c.krbConf.ExpectedRealm, "actual", realm) |
||||
if !c.krbConf.FallbackToPassword { |
||||
c.krbValidator.Challenge(w) |
||||
return nil, true, nil |
||||
} |
||||
c.logger.Info("kerberos realm mismatch but fallback enabled; rendering login form") |
||||
return nil, false, nil |
||||
} |
||||
|
||||
mapped := mapPrincipal(principal, realm, c.krbConf.UsernameFromPrincipal) |
||||
c.logger.Info("kerberos principal mapped", "principal", principal, "realm", realm, "mapped_username", mapped) |
||||
|
||||
var userEntry ldap.Entry |
||||
// Allow test hook override
|
||||
if krbLookupUserHook != nil { |
||||
if v, found, herr := krbLookupUserHook(c, mapped); found { |
||||
if herr != nil { |
||||
return nil, true, herr |
||||
} |
||||
userEntry = v |
||||
} |
||||
} |
||||
|
||||
if userEntry.DN == "" { |
||||
// Reuse existing search logic via do() and userEntry
|
||||
err = c.do(ctx, func(conn *ldap.Conn) error { |
||||
entry, found, err := c.userEntry(conn, mapped) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if !found { |
||||
return fmt.Errorf("user not found for principal") |
||||
} |
||||
userEntry = entry |
||||
return nil |
||||
}) |
||||
} |
||||
if err != nil { |
||||
c.logger.Error("kerberos user lookup failed", "principal", principal, "mapped", mapped, "err", err) |
||||
return nil, true, fmt.Errorf("ldap: user lookup failed for kerberos principal %q: %v", principal, err) |
||||
} |
||||
c.logger.Info("kerberos user lookup succeeded", "dn", userEntry.DN) |
||||
|
||||
ident, err := c.identityFromEntry(userEntry) |
||||
if err != nil { |
||||
c.logger.Info("failed to build identity from LDAP entry after kerberos SPNEGO", "err", err) |
||||
return nil, true, err |
||||
} |
||||
if s.Groups { |
||||
groups, err := c.groups(ctx, userEntry) |
||||
if err != nil { |
||||
c.logger.Info("failed to query groups after kerberos SPNEGO", "err", err) |
||||
return nil, true, fmt.Errorf("ldap: failed to query groups: %v", err) |
||||
} |
||||
ident.Groups = groups |
||||
} |
||||
|
||||
// No password -> no user bind; do not set ConnectorData unless OfflineAccess requested
|
||||
if s.OfflineAccess { |
||||
refresh := refreshData{Username: mapped, Entry: userEntry} |
||||
if data, mErr := json.Marshal(refresh); mErr == nil { |
||||
ident.ConnectorData = data |
||||
} |
||||
} |
||||
|
||||
c.logger.Info("kerberos SPNEGO authentication succeeded", "username", ident.Username, "email", ident.Email, "groups_count", len(ident.Groups)) |
||||
return &ident, true, nil |
||||
} |
||||
@ -0,0 +1,452 @@
|
||||
package ldap |
||||
|
||||
import ( |
||||
"encoding/base64" |
||||
"log/slog" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/dexidp/dex/connector" |
||||
ldaplib "github.com/go-ldap/ldap/v3" |
||||
) |
||||
|
||||
type mockKrbValidator struct { |
||||
principal string |
||||
realm string |
||||
ok bool |
||||
err error |
||||
challenged bool |
||||
contToken []byte |
||||
step int // -1 means disabled; 0->continue, then success
|
||||
} |
||||
|
||||
func (m *mockKrbValidator) ValidateRequest(r *http.Request) (string, string, bool, error) { |
||||
if m.step >= 0 { |
||||
if m.step == 0 { |
||||
return "", "", false, nil |
||||
} |
||||
return m.principal, m.realm, true, nil |
||||
} |
||||
return m.principal, m.realm, m.ok, m.err |
||||
} |
||||
|
||||
func (m *mockKrbValidator) Challenge(w http.ResponseWriter) { |
||||
m.challenged = true |
||||
writeNegotiateChallenge(w) |
||||
} |
||||
|
||||
func (m *mockKrbValidator) ContinueToken(r *http.Request) ([]byte, bool) { |
||||
if m.step >= 0 { |
||||
if m.step == 0 && len(m.contToken) > 0 { |
||||
m.step++ |
||||
return m.contToken, true |
||||
} |
||||
return nil, false |
||||
} |
||||
if len(m.contToken) > 0 { |
||||
return m.contToken, true |
||||
} |
||||
return nil, false |
||||
} |
||||
|
||||
func TestKerberos_NoHeader_Returns401Negotiate(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} |
||||
mv := &mockKrbValidator{principal: "", realm: "", ok: false, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if !bool(handled) { |
||||
t.Fatalf("expected handled") |
||||
} |
||||
if ident != nil { |
||||
t.Fatalf("expected no identity") |
||||
} |
||||
if w.Result().StatusCode != 401 { |
||||
t.Fatalf("expected 401, got %d", w.Result().StatusCode) |
||||
} |
||||
if hdr := w.Header().Get("WWW-Authenticate"); hdr != "Negotiate" { |
||||
t.Fatalf("expected Negotiate challenge") |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_ExpectedRealmMismatch_401(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart", ExpectedRealm: "EXAMPLE.COM"}} |
||||
mv := &mockKrbValidator{principal: "jdoe@OTHER.COM", realm: "OTHER.COM", ok: true, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if !bool(handled) { |
||||
t.Fatalf("expected handled") |
||||
} |
||||
if ident != nil { |
||||
t.Fatalf("expected no identity") |
||||
} |
||||
if w.Result().StatusCode != 401 { |
||||
t.Fatalf("expected 401, got %d", w.Result().StatusCode) |
||||
} |
||||
if hdr := w.Header().Get("WWW-Authenticate"); hdr != "Negotiate" { |
||||
t.Fatalf("expected Negotiate challenge") |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_FallbackToPassword_NoHeader_HandledFalse(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: true, UsernameFromPrincipal: "localpart"}} |
||||
mv := &mockKrbValidator{principal: "", realm: "", ok: false, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if bool(handled) { |
||||
t.Fatalf("expected not handled") |
||||
} |
||||
if ident != nil { |
||||
t.Fatalf("expected no identity") |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_ContinueNeeded_SendsResponseToken(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} |
||||
mv := &mockKrbValidator{principal: "", realm: "", ok: false, err: nil, contToken: []byte{0x01, 0x02, 0x03}} |
||||
lc.krbValidator = mv |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
r.Header.Set("Authorization", "Negotiate Zm9v") |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if !bool(handled) { |
||||
t.Fatalf("expected handled") |
||||
} |
||||
if ident != nil { |
||||
t.Fatalf("expected no identity") |
||||
} |
||||
if w.Code != 401 { |
||||
t.Fatalf("expected 401, got %d", w.Code) |
||||
} |
||||
hdr := w.Header().Get("WWW-Authenticate") |
||||
if !strings.HasPrefix(hdr, "Negotiate ") { |
||||
t.Fatalf("expected Negotiate header, got %q", hdr) |
||||
} |
||||
want := "Negotiate " + base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03}) |
||||
if hdr != want { |
||||
t.Fatalf("unexpected negotiate header, got %q want %q", hdr, want) |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_ContinueThenSuccess_ShortCircuitIdentity(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} |
||||
lc.Config.UserSearch.IDAttr = "uid" |
||||
lc.Config.UserSearch.EmailAttr = "mail" |
||||
lc.Config.UserSearch.NameAttr = "cn" |
||||
mv := &mockKrbValidator{principal: "jdoe@EXAMPLE.COM", realm: "EXAMPLE.COM", contToken: []byte{0xAA, 0xBB}, step: 0} |
||||
lc.krbValidator = mv |
||||
|
||||
// First request -> 401 with continue token
|
||||
r1 := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w1 := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r1.Context(), connector.Scopes{}, w1, r1) |
||||
if err != nil || !bool(handled) || ident != nil || w1.Code != 401 { |
||||
t.Fatalf("unexpected first step result: ident=%v handled=%v code=%d err=%v", ident, handled, w1.Code, err) |
||||
} |
||||
hdr := w1.Header().Get("WWW-Authenticate") |
||||
if hdr == "" || !strings.HasPrefix(hdr, "Negotiate ") { |
||||
t.Fatalf("expected Negotiate header on first step, got %q", hdr) |
||||
} |
||||
|
||||
// Second request -> validator now returns ok=true (due to step increment)
|
||||
krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { |
||||
e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ |
||||
c.UserSearch.IDAttr: {"uid-jdoe"}, |
||||
c.UserSearch.EmailAttr: {"jdoe@example.com"}, |
||||
c.UserSearch.NameAttr: {"John Doe"}, |
||||
}) |
||||
return *e, true, nil |
||||
} |
||||
defer func() { krbLookupUserHook = nil }() |
||||
r2 := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w2 := httptest.NewRecorder() |
||||
ident2, handled2, err2 := lc.TrySPNEGO(r2.Context(), connector.Scopes{}, w2, r2) |
||||
if err2 != nil { |
||||
t.Fatalf("unexpected err: %v", err2) |
||||
} |
||||
if !bool(handled2) || ident2 == nil { |
||||
t.Fatalf("expected handled with identity on second step") |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_ContinueNeeded_FallbackTrue_NotHandled(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: true, UsernameFromPrincipal: "localpart"}} |
||||
mv := &mockKrbValidator{contToken: []byte{0x10}, step: 0} |
||||
lc.krbValidator = mv |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if bool(handled) || ident != nil { |
||||
t.Fatalf("expected not handled with fallback=true when continue is needed") |
||||
} |
||||
if w.Code != 200 && w.Code != 0 { |
||||
t.Fatalf("expected no response written yet, got %d", w.Code) |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_mapPrincipal(t *testing.T) { |
||||
cases := []struct{ in, realm, mode, want string }{ |
||||
{"JDoe@EXAMPLE.COM", "EXAMPLE.COM", "localpart", "jdoe"}, |
||||
{"JDoe@EXAMPLE.COM", "EXAMPLE.COM", "sAMAccountName", "jdoe"}, |
||||
{"JDoe@EXAMPLE.COM", "EXAMPLE.COM", "userPrincipalName", "jdoe@example.com"}, |
||||
} |
||||
for _, c := range cases { |
||||
got := mapPrincipal(c.in, c.realm, c.mode) |
||||
if got != c.want { |
||||
t.Fatalf("mapPrincipal(%q,%q,%q)=%q; want %q", c.in, c.realm, c.mode, got, c.want) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_UserNotFound_ReturnsError(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} |
||||
mv := &mockKrbValidator{principal: "jdoe@EXAMPLE.COM", realm: "EXAMPLE.COM", ok: true, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err == nil { |
||||
t.Fatalf("expected error for user not found") |
||||
} |
||||
if !bool(handled) { |
||||
t.Fatalf("expected handled") |
||||
} |
||||
if ident != nil { |
||||
t.Fatalf("expected no identity") |
||||
} |
||||
if !strings.Contains(err.Error(), "user lookup failed") { |
||||
t.Fatalf("expected 'user lookup failed' error, got: %v", err) |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_ValidPrincipal_CompletesFlow(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} |
||||
lc.Config.UserSearch.IDAttr = "uid" |
||||
lc.Config.UserSearch.EmailAttr = "mail" |
||||
lc.Config.UserSearch.NameAttr = "cn" |
||||
mv := &mockKrbValidator{principal: "jdoe@EXAMPLE.COM", realm: "EXAMPLE.COM", ok: true, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { |
||||
e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ |
||||
c.UserSearch.IDAttr: {"uid-jdoe"}, |
||||
c.UserSearch.EmailAttr: {"jdoe@example.com"}, |
||||
c.UserSearch.NameAttr: {"John Doe"}, |
||||
}) |
||||
return *e, true, nil |
||||
} |
||||
defer func() { krbLookupUserHook = nil }() |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if !bool(handled) { |
||||
t.Fatalf("expected handled") |
||||
} |
||||
if ident == nil { |
||||
t.Fatalf("expected identity") |
||||
} |
||||
if ident.Username == "" || ident.Email == "" || ident.UserID == "" { |
||||
t.Fatalf("expected populated identity, got %+v", *ident) |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_InvalidHeader_Returns401Negotiate(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} |
||||
mv := &mockKrbValidator{principal: "", realm: "", ok: false, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
r.Header.Set("Authorization", "Negotiate !!!notbase64!!!") |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if !bool(handled) { |
||||
t.Fatalf("expected handled") |
||||
} |
||||
if ident != nil { |
||||
t.Fatalf("expected no identity") |
||||
} |
||||
if w.Result().StatusCode != 401 { |
||||
t.Fatalf("expected 401, got %d", w.Result().StatusCode) |
||||
} |
||||
if hdr := w.Header().Get("WWW-Authenticate"); hdr != "Negotiate" { |
||||
t.Fatalf("expected Negotiate challenge") |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_UserPrincipalName_Mapping(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "userPrincipalName"}} |
||||
lc.Config.UserSearch.IDAttr = "uid" |
||||
lc.Config.UserSearch.EmailAttr = "mail" |
||||
lc.Config.UserSearch.NameAttr = "cn" |
||||
mv := &mockKrbValidator{principal: "J.Doe@Example.COM", realm: "Example.COM", ok: true, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { |
||||
if username != "j.doe@example.com" { |
||||
return ldaplib.Entry{}, false, nil |
||||
} |
||||
e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ |
||||
c.UserSearch.IDAttr: {"uid-jdoe"}, |
||||
c.UserSearch.EmailAttr: {"jdoe@example.com"}, |
||||
c.UserSearch.NameAttr: {"John Doe"}, |
||||
}) |
||||
return *e, true, nil |
||||
} |
||||
defer func() { krbLookupUserHook = nil }() |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if !bool(handled) { |
||||
t.Fatalf("expected handled") |
||||
} |
||||
if ident == nil { |
||||
t.Fatalf("expected identity") |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_OfflineAccess_SetsConnectorData(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart"}} |
||||
lc.Config.UserSearch.IDAttr = "uid" |
||||
lc.Config.UserSearch.EmailAttr = "mail" |
||||
lc.Config.UserSearch.NameAttr = "cn" |
||||
mv := &mockKrbValidator{principal: "jdoe@EXAMPLE.COM", realm: "EXAMPLE.COM", ok: true, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { |
||||
e := ldaplib.NewEntry("cn=jdoe,dc=example,dc=org", map[string][]string{ |
||||
c.UserSearch.IDAttr: {"uid-jdoe"}, |
||||
c.UserSearch.EmailAttr: {"jdoe@example.com"}, |
||||
c.UserSearch.NameAttr: {"John Doe"}, |
||||
}) |
||||
return *e, true, nil |
||||
} |
||||
defer func() { krbLookupUserHook = nil }() |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w := httptest.NewRecorder() |
||||
scopes := connector.Scopes{OfflineAccess: true} |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), scopes, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if !bool(handled) { |
||||
t.Fatalf("expected handled") |
||||
} |
||||
if ident == nil { |
||||
t.Fatalf("expected identity") |
||||
} |
||||
if len(ident.ConnectorData) == 0 { |
||||
t.Fatalf("expected connector data for offline access") |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_FallbackTrue_InvalidHeader_NotHandled(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: true, UsernameFromPrincipal: "localpart"}} |
||||
mv := &mockKrbValidator{principal: "", realm: "", ok: false, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
r.Header.Set("Authorization", "Negotiate !!!") |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if bool(handled) { |
||||
t.Fatalf("expected not handled for fallback path") |
||||
} |
||||
if ident != nil { |
||||
t.Fatalf("expected no identity") |
||||
} |
||||
if w.Code != 200 && w.Code != 0 { |
||||
t.Fatalf("expected no response written yet, got %d", w.Code) |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_sAMAccountName_EqualsLocalpart(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "sAMAccountName"}} |
||||
lc.Config.UserSearch.IDAttr = "uid" |
||||
lc.Config.UserSearch.EmailAttr = "mail" |
||||
lc.Config.UserSearch.NameAttr = "cn" |
||||
mv := &mockKrbValidator{principal: "Admin@REALM.LOCAL", realm: "REALM.LOCAL", ok: true, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { |
||||
if username != "admin" { |
||||
return ldaplib.Entry{}, false, nil |
||||
} |
||||
e := ldaplib.NewEntry("cn=admin,dc=local", map[string][]string{ |
||||
c.UserSearch.IDAttr: {"uid-admin"}, |
||||
c.UserSearch.EmailAttr: {"admin@local"}, |
||||
c.UserSearch.NameAttr: {"Admin"}, |
||||
}) |
||||
return *e, true, nil |
||||
} |
||||
defer func() { krbLookupUserHook = nil }() |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if !bool(handled) { |
||||
t.Fatalf("expected handled") |
||||
} |
||||
if ident == nil { |
||||
t.Fatalf("expected identity") |
||||
} |
||||
} |
||||
|
||||
func TestKerberos_ExpectedRealm_CaseInsensitive(t *testing.T) { |
||||
lc := &ldapConnector{logger: slog.Default(), krbEnabled: true, krbConf: kerberosConfig{FallbackToPassword: false, UsernameFromPrincipal: "localpart", ExpectedRealm: "ExAmPlE.CoM"}} |
||||
lc.Config.UserSearch.IDAttr = "uid" |
||||
lc.Config.UserSearch.EmailAttr = "mail" |
||||
lc.Config.UserSearch.NameAttr = "cn" |
||||
mv := &mockKrbValidator{principal: "user@EXAMPLE.COM", realm: "EXAMPLE.COM", ok: true, err: nil, step: -1} |
||||
lc.krbValidator = mv |
||||
krbLookupUserHook = func(c *ldapConnector, username string) (ldaplib.Entry, bool, error) { |
||||
e := ldaplib.NewEntry("cn=user,dc=example,dc=com", map[string][]string{ |
||||
c.UserSearch.IDAttr: {"uid-user"}, |
||||
c.UserSearch.EmailAttr: {"user@example.com"}, |
||||
c.UserSearch.NameAttr: {"User"}, |
||||
}) |
||||
return *e, true, nil |
||||
} |
||||
defer func() { krbLookupUserHook = nil }() |
||||
r := httptest.NewRequest("GET", "/auth/ldap/login?state=abc", nil) |
||||
w := httptest.NewRecorder() |
||||
ident, handled, err := lc.TrySPNEGO(r.Context(), connector.Scopes{}, w, r) |
||||
if err != nil { |
||||
t.Fatalf("unexpected err: %v", err) |
||||
} |
||||
if !bool(handled) { |
||||
t.Fatalf("expected handled") |
||||
} |
||||
if ident == nil { |
||||
t.Fatalf("expected identity") |
||||
} |
||||
} |
||||
@ -0,0 +1,22 @@
|
||||
package connector |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
) |
||||
|
||||
// Handled indicates whether the SPNEGO-aware connector handled the request.
|
||||
type Handled bool |
||||
|
||||
// SPNEGOAware is an optional extension for connectors that can authenticate
|
||||
// users via Kerberos SPNEGO on the initial GET to the password login endpoint.
|
||||
//
|
||||
// If handled is true and ident is non-nil, the caller should complete the
|
||||
// OAuth flow as with a successful password login. If handled is true and
|
||||
// ident is nil, the implementation has already written an appropriate
|
||||
// response (e.g., 401 with WWW-Authenticate: Negotiate) and the caller should
|
||||
// return without rendering the password form. If handled is false, proceed
|
||||
// with the legacy password form flow.
|
||||
type SPNEGOAware interface { |
||||
TrySPNEGO(ctx context.Context, s Scopes, w http.ResponseWriter, r *http.Request) (*Identity, Handled, error) |
||||
} |
||||
Loading…
Reference in new issue