|
| 1 | +// Package ldap implements strategies for authenticating using the LDAP protocol. |
| 2 | +// This file contains Kerberos/SPNEGO authentication support for LDAP connector. |
| 3 | +package ldap |
| 4 | + |
| 5 | +import ( |
| 6 | + "context" |
| 7 | + "encoding/base64" |
| 8 | + "encoding/json" |
| 9 | + "fmt" |
| 10 | + "log/slog" |
| 11 | + "net/http" |
| 12 | + "os" |
| 13 | + "strings" |
| 14 | + |
| 15 | + "github.com/jcmturner/gofork/encoding/asn1" |
| 16 | + "github.com/jcmturner/gokrb5/v8/credentials" |
| 17 | + "github.com/jcmturner/gokrb5/v8/gssapi" |
| 18 | + "github.com/jcmturner/gokrb5/v8/keytab" |
| 19 | + "github.com/jcmturner/gokrb5/v8/service" |
| 20 | + "github.com/jcmturner/gokrb5/v8/spnego" |
| 21 | + "github.com/jcmturner/gokrb5/v8/types" |
| 22 | + |
| 23 | + "github.com/dexidp/dex/connector" |
| 24 | + "github.com/go-ldap/ldap/v3" |
| 25 | +) |
| 26 | + |
| 27 | +// KerberosValidator abstracts SPNEGO validation for unit-testing. |
| 28 | +type KerberosValidator interface { |
| 29 | + // ValidateRequest returns (principal, realm, ok, err). ok=false means header missing/invalid. |
| 30 | + ValidateRequest(r *http.Request) (string, string, bool, error) |
| 31 | + // Challenge writes a 401 Negotiate challenge. |
| 32 | + Challenge(w http.ResponseWriter) |
| 33 | + // ContinueToken tries to advance SPNEGO handshake and returns a response token to include |
| 34 | + // in WWW-Authenticate: Negotiate <token>. Returns (nil, false) if no continuation is needed/possible. |
| 35 | + ContinueToken(r *http.Request) ([]byte, bool) |
| 36 | +} |
| 37 | + |
| 38 | +// writeNegotiateChallenge writes a standard 401 Negotiate challenge. |
| 39 | +func writeNegotiateChallenge(w http.ResponseWriter) { |
| 40 | + w.Header().Set("WWW-Authenticate", "Negotiate") |
| 41 | + w.WriteHeader(http.StatusUnauthorized) |
| 42 | +} |
| 43 | + |
| 44 | +// mapPrincipal maps a Kerberos principal to LDAP username per configuration. |
| 45 | +func mapPrincipal(principal, realm, mapping string) string { |
| 46 | + p := principal |
| 47 | + switch strings.ToLower(mapping) { |
| 48 | + case "localpart", "samaccountname": |
| 49 | + if i := strings.IndexByte(principal, '@'); i >= 0 { |
| 50 | + p = principal[:i] |
| 51 | + } |
| 52 | + return strings.ToLower(p) |
| 53 | + case "userprincipalname": |
| 54 | + return strings.ToLower(principal) |
| 55 | + default: |
| 56 | + if i := strings.IndexByte(principal, '@'); i >= 0 { |
| 57 | + p = principal[:i] |
| 58 | + } |
| 59 | + return strings.ToLower(p) |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +// gokrb5 implementation of KerberosValidator |
| 64 | + |
| 65 | +// context key used by gokrb5 to store credentials in the context |
| 66 | +var ctxCredentialsKey interface{} = "github.com/jcmturner/gokrb5/v8/ctxCredentials" |
| 67 | + |
| 68 | +// SPNEGO NegTokenResp (AcceptIncomplete + KRB5 mech) base64 payload used by gokrb5's HTTP server |
| 69 | +// to prompt the client to continue the handshake. |
| 70 | +const spnegoIncompleteKRB5B64 = "oRQwEqADCgEBoQsGCSqGSIb3EgECAg==" |
| 71 | + |
| 72 | +type gokrb5Validator struct { |
| 73 | + kt *keytab.Keytab |
| 74 | + logger *slog.Logger |
| 75 | +} |
| 76 | + |
| 77 | +func newGokrb5ValidatorWithLogger(keytabPath string, logger *slog.Logger) (KerberosValidator, error) { |
| 78 | + kt, err := keytab.Load(keytabPath) |
| 79 | + if err != nil { |
| 80 | + return nil, fmt.Errorf("failed to load keytab: %w", err) |
| 81 | + } |
| 82 | + if fi, err := os.Stat(keytabPath); err != nil || fi.IsDir() { |
| 83 | + return nil, fmt.Errorf("invalid keytab path: %s", keytabPath) |
| 84 | + } |
| 85 | + if logger == nil { |
| 86 | + logger = slog.Default() |
| 87 | + } |
| 88 | + return &gokrb5Validator{kt: kt, logger: logger}, nil |
| 89 | +} |
| 90 | + |
| 91 | +func (v *gokrb5Validator) ValidateRequest(r *http.Request) (string, string, bool, error) { |
| 92 | + h := r.Header.Get("Authorization") |
| 93 | + if h == "" || !strings.HasPrefix(h, "Negotiate ") { |
| 94 | + if v.logger != nil { |
| 95 | + v.logger.Info("kerberos: missing or non-negotiate Authorization header", "path", r.URL.Path) |
| 96 | + } |
| 97 | + return "", "", false, nil |
| 98 | + } |
| 99 | + b64 := strings.TrimSpace(h[len("Negotiate "):]) |
| 100 | + if b64 == "" { |
| 101 | + if v.logger != nil { |
| 102 | + v.logger.Info("kerberos: empty negotiate token", "path", r.URL.Path) |
| 103 | + } |
| 104 | + return "", "", false, nil |
| 105 | + } |
| 106 | + data, err := base64.StdEncoding.DecodeString(b64) |
| 107 | + if err != nil { |
| 108 | + if v.logger != nil { |
| 109 | + v.logger.Info("kerberos: invalid base64 in Authorization", "err", err) |
| 110 | + } |
| 111 | + return "", "", false, nil |
| 112 | + } |
| 113 | + var tok spnego.SPNEGOToken |
| 114 | + if err := tok.Unmarshal(data); err != nil { |
| 115 | + // Try raw KRB5 token and wrap |
| 116 | + var k5 spnego.KRB5Token |
| 117 | + if k5.Unmarshal(data) != nil { |
| 118 | + if v.logger != nil { |
| 119 | + v.logger.Info("kerberos: failed to unmarshal SPNEGO token and not raw KRB5", "err", err) |
| 120 | + } |
| 121 | + return "", "", false, nil |
| 122 | + } |
| 123 | + tok.Init = true |
| 124 | + tok.NegTokenInit = spnego.NegTokenInit{ |
| 125 | + MechTypes: []asn1.ObjectIdentifier{k5.OID}, |
| 126 | + MechTokenBytes: data, |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + // Pass client address when available (improves AP-REQ validation with address-bound tickets) |
| 131 | + var sp *spnego.SPNEGO |
| 132 | + if ha, err := types.GetHostAddress(r.RemoteAddr); err == nil { |
| 133 | + sp = spnego.SPNEGOService(v.kt, service.ClientAddress(ha), service.DecodePAC(false)) |
| 134 | + } else { |
| 135 | + if v.logger != nil { |
| 136 | + v.logger.Info("kerberos: cannot parse client address", "remote", r.RemoteAddr, "err", err) |
| 137 | + } |
| 138 | + sp = spnego.SPNEGOService(v.kt, service.DecodePAC(false)) |
| 139 | + } |
| 140 | + authed, ctx, status := sp.AcceptSecContext(&tok) |
| 141 | + if status.Code != gssapi.StatusComplete { |
| 142 | + if v.logger != nil { |
| 143 | + v.logger.Info("kerberos: AcceptSecContext not complete", "code", status.Code, "message", status.Message) |
| 144 | + } |
| 145 | + return "", "", false, nil |
| 146 | + } |
| 147 | + if !authed || ctx == nil { |
| 148 | + if v.logger != nil { |
| 149 | + v.logger.Info("kerberos: not authenticated or no context") |
| 150 | + } |
| 151 | + return "", "", false, nil |
| 152 | + } |
| 153 | + id, _ := ctx.Value(ctxCredentialsKey).(*credentials.Credentials) |
| 154 | + if id == nil { |
| 155 | + if v.logger != nil { |
| 156 | + v.logger.Info("kerberos: credentials missing in context") |
| 157 | + } |
| 158 | + return "", "", false, fmt.Errorf("no credentials in context") |
| 159 | + } |
| 160 | + return id.UserName(), id.Domain(), true, nil |
| 161 | +} |
| 162 | + |
| 163 | +func (v *gokrb5Validator) Challenge(w http.ResponseWriter) { writeNegotiateChallenge(w) } |
| 164 | + |
| 165 | +// ContinueToken attempts to continue the SPNEGO handshake and returns a response token |
| 166 | +// (to be placed into WWW-Authenticate: Negotiate <b64>) if available. |
| 167 | +func (v *gokrb5Validator) ContinueToken(r *http.Request) ([]byte, bool) { |
| 168 | + h := r.Header.Get("Authorization") |
| 169 | + if h == "" || !strings.HasPrefix(h, "Negotiate ") { |
| 170 | + if v.logger != nil { |
| 171 | + v.logger.Info("kerberos: ContinueToken without negotiate header", "path", r.URL.Path) |
| 172 | + } |
| 173 | + return nil, false |
| 174 | + } |
| 175 | + b64 := strings.TrimSpace(h[len("Negotiate "):]) |
| 176 | + data, err := base64.StdEncoding.DecodeString(b64) |
| 177 | + if err != nil { |
| 178 | + // Malformed header: ask client to continue with KRB5 mech |
| 179 | + if tok, e := base64.StdEncoding.DecodeString(spnegoIncompleteKRB5B64); e == nil { |
| 180 | + if v.logger != nil { |
| 181 | + v.logger.Info("kerberos: malformed negotiate token; sending incomplete KRB5 response") |
| 182 | + } |
| 183 | + return tok, true |
| 184 | + } |
| 185 | + return nil, false |
| 186 | + } |
| 187 | + var tok spnego.SPNEGOToken |
| 188 | + if err := tok.Unmarshal(data); err != nil { |
| 189 | + // Not a full SPNEGO token; still ask client to continue |
| 190 | + if tokb, e := base64.StdEncoding.DecodeString(spnegoIncompleteKRB5B64); e == nil { |
| 191 | + if v.logger != nil { |
| 192 | + v.logger.Info("kerberos: non-SPNEGO token; sending incomplete KRB5 response") |
| 193 | + } |
| 194 | + return tokb, true |
| 195 | + } |
| 196 | + // As a fallback, try wrapping as raw KRB5 |
| 197 | + var k5 spnego.KRB5Token |
| 198 | + if k5.Unmarshal(data) != nil { |
| 199 | + if v.logger != nil { |
| 200 | + v.logger.Info("kerberos: not KRB5 token; cannot continue") |
| 201 | + } |
| 202 | + return nil, false |
| 203 | + } |
| 204 | + tok.Init = true |
| 205 | + tok.NegTokenInit = spnego.NegTokenInit{MechTypes: []asn1.ObjectIdentifier{k5.OID}, MechTokenBytes: data} |
| 206 | + } |
| 207 | + // Try continue with same options as in ValidateRequest |
| 208 | + var sp *spnego.SPNEGO |
| 209 | + if ha, err := types.GetHostAddress(r.RemoteAddr); err == nil { |
| 210 | + sp = spnego.SPNEGOService(v.kt, service.ClientAddress(ha), service.DecodePAC(false)) |
| 211 | + } else { |
| 212 | + sp = spnego.SPNEGOService(v.kt, service.DecodePAC(false)) |
| 213 | + } |
| 214 | + _, ctx, status := sp.AcceptSecContext(&tok) |
| 215 | + if status.Code != gssapi.StatusContinueNeeded || ctx == nil { |
| 216 | + if v.logger != nil { |
| 217 | + v.logger.Info("kerberos: no continuation required", "code", status.Code, "message", status.Message) |
| 218 | + } |
| 219 | + return nil, false |
| 220 | + } |
| 221 | + // Ask client to continue using standard NegTokenResp (KRB5, incomplete) |
| 222 | + if tokb, e := base64.StdEncoding.DecodeString(spnegoIncompleteKRB5B64); e == nil { |
| 223 | + if v.logger != nil { |
| 224 | + v.logger.Info("kerberos: continuation needed; sending incomplete KRB5 response") |
| 225 | + } |
| 226 | + return tokb, true |
| 227 | + } |
| 228 | + return nil, false |
| 229 | +} |
| 230 | + |
| 231 | +// LDAP connector SPNEGO integration |
| 232 | + |
| 233 | +// krbLookupUserHook allows tests to inject a user entry without LDAP queries. |
| 234 | +var krbLookupUserHook func(c *ldapConnector, username string) (ldap.Entry, bool, error) |
| 235 | + |
| 236 | +// TrySPNEGO attempts Kerberos auth and builds identity on success. |
| 237 | +func (c *ldapConnector) TrySPNEGO(ctx context.Context, s connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) { |
| 238 | + if !c.krbEnabled || c.krbValidator == nil { |
| 239 | + return nil, false, nil |
| 240 | + } |
| 241 | + |
| 242 | + principal, realm, ok, err := c.krbValidator.ValidateRequest(r) |
| 243 | + if err != nil || !ok { |
| 244 | + if !c.krbConf.FallbackToPassword { |
| 245 | + // Try to get a continuation token to advance SPNEGO handshake |
| 246 | + if tok, ok2 := c.krbValidator.ContinueToken(r); ok2 && len(tok) > 0 { |
| 247 | + c.logger.Info("kerberos SPNEGO continuation required; sending response token") |
| 248 | + w.Header().Set("WWW-Authenticate", "Negotiate "+base64.StdEncoding.EncodeToString(tok)) |
| 249 | + w.WriteHeader(http.StatusUnauthorized) |
| 250 | + return nil, true, nil |
| 251 | + } |
| 252 | + if err != nil { |
| 253 | + c.logger.Info("kerberos SPNEGO validation error; sending Negotiate challenge", "err", err) |
| 254 | + } else { |
| 255 | + c.logger.Info("kerberos SPNEGO not completed or header missing; sending Negotiate challenge") |
| 256 | + } |
| 257 | + c.krbValidator.Challenge(w) |
| 258 | + return nil, true, nil |
| 259 | + } |
| 260 | + c.logger.Info("kerberos SPNEGO fallback to password enabled; rendering login form") |
| 261 | + return nil, false, nil |
| 262 | + } |
| 263 | + |
| 264 | + if c.krbConf.ExpectedRealm != "" && !strings.EqualFold(c.krbConf.ExpectedRealm, realm) { |
| 265 | + c.logger.Info("kerberos realm mismatch", "expected", c.krbConf.ExpectedRealm, "actual", realm) |
| 266 | + if !c.krbConf.FallbackToPassword { |
| 267 | + c.krbValidator.Challenge(w) |
| 268 | + return nil, true, nil |
| 269 | + } |
| 270 | + c.logger.Info("kerberos realm mismatch but fallback enabled; rendering login form") |
| 271 | + return nil, false, nil |
| 272 | + } |
| 273 | + |
| 274 | + mapped := mapPrincipal(principal, realm, c.krbConf.UsernameFromPrincipal) |
| 275 | + c.logger.Info("kerberos principal mapped", "principal", principal, "realm", realm, "mapped_username", mapped) |
| 276 | + |
| 277 | + var userEntry ldap.Entry |
| 278 | + // Allow test hook override |
| 279 | + if krbLookupUserHook != nil { |
| 280 | + if v, found, herr := krbLookupUserHook(c, mapped); found { |
| 281 | + if herr != nil { |
| 282 | + return nil, true, herr |
| 283 | + } |
| 284 | + userEntry = v |
| 285 | + } |
| 286 | + } |
| 287 | + |
| 288 | + if userEntry.DN == "" { |
| 289 | + // Reuse existing search logic via do() and userEntry |
| 290 | + err = c.do(ctx, func(conn *ldap.Conn) error { |
| 291 | + entry, found, err := c.userEntry(conn, mapped) |
| 292 | + if err != nil { |
| 293 | + return err |
| 294 | + } |
| 295 | + if !found { |
| 296 | + return fmt.Errorf("user not found for principal") |
| 297 | + } |
| 298 | + userEntry = entry |
| 299 | + return nil |
| 300 | + }) |
| 301 | + } |
| 302 | + if err != nil { |
| 303 | + c.logger.Error("kerberos user lookup failed", "principal", principal, "mapped", mapped, "err", err) |
| 304 | + return nil, true, fmt.Errorf("ldap: user lookup failed for kerberos principal %q: %v", principal, err) |
| 305 | + } |
| 306 | + c.logger.Info("kerberos user lookup succeeded", "dn", userEntry.DN) |
| 307 | + |
| 308 | + ident, err := c.identityFromEntry(userEntry) |
| 309 | + if err != nil { |
| 310 | + c.logger.Info("failed to build identity from LDAP entry after kerberos SPNEGO", "err", err) |
| 311 | + return nil, true, err |
| 312 | + } |
| 313 | + if s.Groups { |
| 314 | + groups, err := c.groups(ctx, userEntry) |
| 315 | + if err != nil { |
| 316 | + c.logger.Info("failed to query groups after kerberos SPNEGO", "err", err) |
| 317 | + return nil, true, fmt.Errorf("ldap: failed to query groups: %v", err) |
| 318 | + } |
| 319 | + ident.Groups = groups |
| 320 | + } |
| 321 | + |
| 322 | + // No password -> no user bind; do not set ConnectorData unless OfflineAccess requested |
| 323 | + if s.OfflineAccess { |
| 324 | + refresh := refreshData{Username: mapped, Entry: userEntry} |
| 325 | + if data, mErr := json.Marshal(refresh); mErr == nil { |
| 326 | + ident.ConnectorData = data |
| 327 | + } |
| 328 | + } |
| 329 | + |
| 330 | + c.logger.Info("kerberos SPNEGO authentication succeeded", "username", ident.Username, "email", ident.Email, "groups_count", len(ident.Groups)) |
| 331 | + return &ident, true, nil |
| 332 | +} |
0 commit comments