|
| 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 | + "bytes" |
| 7 | + "context" |
| 8 | + "encoding/json" |
| 9 | + "fmt" |
| 10 | + "net/http" |
| 11 | + "os" |
| 12 | + "strings" |
| 13 | + "time" |
| 14 | + |
| 15 | + "github.com/go-ldap/ldap/v3" |
| 16 | + "github.com/jcmturner/goidentity/v6" |
| 17 | + "github.com/jcmturner/gokrb5/v8/credentials" |
| 18 | + "github.com/jcmturner/gokrb5/v8/keytab" |
| 19 | + "github.com/jcmturner/gokrb5/v8/service" |
| 20 | + "github.com/jcmturner/gokrb5/v8/spnego" |
| 21 | + |
| 22 | + "github.com/dexidp/dex/connector" |
| 23 | +) |
| 24 | + |
| 25 | +// bufferedResponse is a minimal http.ResponseWriter that buffers status, |
| 26 | +// headers, and body in memory so the SPNEGO middleware's response can be |
| 27 | +// inspected and conditionally forwarded. spnego.SPNEGOKRB5Authenticate is a |
| 28 | +// "terminal" middleware (it commits failure responses directly to the |
| 29 | +// ResponseWriter), but Dex needs to layer policy on top — FallbackToPassword |
| 30 | +// may want to discard a 401 so the password form can render, and |
| 31 | +// ExpectedRealm checks happen after a successful auth. Buffering decouples |
| 32 | +// the middleware's output from the eventual client response. |
| 33 | +type bufferedResponse struct { |
| 34 | + header http.Header |
| 35 | + body bytes.Buffer |
| 36 | + code int |
| 37 | + wroteHeader bool |
| 38 | +} |
| 39 | + |
| 40 | +func newBufferedResponse() *bufferedResponse { |
| 41 | + return &bufferedResponse{ |
| 42 | + header: make(http.Header), |
| 43 | + code: http.StatusOK, |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +func (r *bufferedResponse) Header() http.Header { |
| 48 | + return r.header |
| 49 | +} |
| 50 | + |
| 51 | +// WriteHeader mirrors net/http: the first call wins, subsequent calls are |
| 52 | +// silently ignored (stdlib emits a "superfluous WriteHeader" warning and |
| 53 | +// keeps the original status; we just drop the override). |
| 54 | +func (r *bufferedResponse) WriteHeader(code int) { |
| 55 | + if r.wroteHeader { |
| 56 | + return |
| 57 | + } |
| 58 | + r.code = code |
| 59 | + r.wroteHeader = true |
| 60 | +} |
| 61 | + |
| 62 | +// Write mirrors net/http: a Write before any WriteHeader implicitly commits |
| 63 | +// status 200, locking the status against later overrides. |
| 64 | +func (r *bufferedResponse) Write(b []byte) (int, error) { |
| 65 | + if !r.wroteHeader { |
| 66 | + r.WriteHeader(http.StatusOK) |
| 67 | + } |
| 68 | + return r.body.Write(b) |
| 69 | +} |
| 70 | + |
| 71 | +// krbState holds Kerberos/SPNEGO configuration bound to a ldapConnector. |
| 72 | +// |
| 73 | +// The authenticate field is the SPNEGO HTTP middleware factory: it wraps an |
| 74 | +// inner http.Handler so that inner runs only after successful SPNEGO auth, |
| 75 | +// with an authenticated *credentials.Credentials attached to the request |
| 76 | +// context under goidentity.CTXKey. In production it delegates to |
| 77 | +// spnego.SPNEGOKRB5Authenticate. Unit tests replace it with a stub that |
| 78 | +// injects a fake credential without any real Kerberos exchange. |
| 79 | +type krbState struct { |
| 80 | + authenticate func(inner http.Handler) http.Handler |
| 81 | +} |
| 82 | + |
| 83 | +// loadKerberosState validates the keytab and returns a krbState wired to |
| 84 | +// spnego.SPNEGOKRB5Authenticate with the requested service settings. |
| 85 | +func loadKerberosState(cfg kerberosConfig) (*krbState, error) { |
| 86 | + fi, err := os.Stat(cfg.KeytabPath) |
| 87 | + if err != nil { |
| 88 | + return nil, fmt.Errorf("keytab file not found: %w", err) |
| 89 | + } |
| 90 | + if fi.IsDir() { |
| 91 | + return nil, fmt.Errorf("keytab path is a directory: %s", cfg.KeytabPath) |
| 92 | + } |
| 93 | + kt, err := keytab.Load(cfg.KeytabPath) |
| 94 | + if err != nil { |
| 95 | + return nil, fmt.Errorf("failed to load keytab: %w", err) |
| 96 | + } |
| 97 | + |
| 98 | + settings := []func(*service.Settings){service.DecodePAC(false)} |
| 99 | + if cfg.SPN != "" { |
| 100 | + settings = append(settings, service.SName(cfg.SPN)) |
| 101 | + } |
| 102 | + if cfg.KeytabPrincipal != "" { |
| 103 | + settings = append(settings, service.KeytabPrincipal(cfg.KeytabPrincipal)) |
| 104 | + } |
| 105 | + if cfg.MaxClockSkew > 0 { |
| 106 | + settings = append(settings, service.MaxClockSkew(time.Duration(cfg.MaxClockSkew)*time.Second)) |
| 107 | + } |
| 108 | + |
| 109 | + return &krbState{ |
| 110 | + authenticate: func(inner http.Handler) http.Handler { |
| 111 | + return spnego.SPNEGOKRB5Authenticate(inner, kt, settings...) |
| 112 | + }, |
| 113 | + }, nil |
| 114 | +} |
| 115 | + |
| 116 | +// mapPrincipal builds the LDAP-side username from the Kerberos credentials |
| 117 | +// according to the configured mapping. Inputs come from gokrb5: |
| 118 | +// username is credentials.UserName() (bare, no realm); realm is Domain(). |
| 119 | +// |
| 120 | +// - "userprincipalname": "username@realm". |
| 121 | +// - "localpart"/"samaccountname" (default): bare username. |
| 122 | +// |
| 123 | +// Case of the output is preserved; callers rely on the LDAP server's |
| 124 | +// attribute matching rules (typically case-insensitive) for comparisons. |
| 125 | +func mapPrincipal(username, realm, mapping string) string { |
| 126 | + // Defensive: if username somehow carries an '@', trim to before it; we |
| 127 | + // always derive the realm from the separate Domain() field. |
| 128 | + if i := strings.IndexByte(username, '@'); i >= 0 { |
| 129 | + username = username[:i] |
| 130 | + } |
| 131 | + if strings.EqualFold(mapping, "userprincipalname") { |
| 132 | + if realm == "" { |
| 133 | + return username |
| 134 | + } |
| 135 | + return username + "@" + realm |
| 136 | + } |
| 137 | + return username |
| 138 | +} |
| 139 | + |
| 140 | +// TrySPNEGO attempts SPNEGO authentication against the LDAP connector's keytab. |
| 141 | +// |
| 142 | +// Behavior: |
| 143 | +// - Kerberos disabled: returns (nil, false, nil) so the caller renders the |
| 144 | +// password form. |
| 145 | +// - FallbackToPassword=true with no Authorization header: a cookie probe |
| 146 | +// gives the browser exactly one round to negotiate. The first such |
| 147 | +// request sets a short-lived "tried" cookie and forwards the middleware's |
| 148 | +// 401 Negotiate challenge so a Kerberos-aware client can respond. If the |
| 149 | +// follow-up request still has no Authorization header (cookie present), |
| 150 | +// we treat the client as unable to SPNEGO and render the password form. |
| 151 | +// - FallbackToPassword=true with an Authorization header that the |
| 152 | +// middleware rejects: render the password form (the client tried and |
| 153 | +// failed; do not loop 401s). |
| 154 | +// - FallbackToPassword=false: forward the middleware's response verbatim. |
| 155 | +// - On success, the authenticated principal is resolved in LDAP and a |
| 156 | +// connector.Identity is returned. Any prior probe cookie is cleared. |
| 157 | +func (c *ldapConnector) TrySPNEGO(ctx context.Context, s connector.Scopes, w http.ResponseWriter, r *http.Request) (*connector.Identity, connector.Handled, error) { |
| 158 | + if c.krb == nil { |
| 159 | + return nil, false, nil |
| 160 | + } |
| 161 | + |
| 162 | + hasNegotiate := strings.HasPrefix(r.Header.Get("Authorization"), "Negotiate ") |
| 163 | + |
| 164 | + // Cookie probe: see the package-level doc on spnegoProbeCookieName. Only |
| 165 | + // applies when fallback is enabled and the client has not (yet) sent a |
| 166 | + // SPNEGO token; "Authorization present" paths bypass the probe entirely. |
| 167 | + if c.krbConf.FallbackToPassword && !hasNegotiate { |
| 168 | + if hasSPNEGOProbeCookie(r) { |
| 169 | + c.logger.Info("kerberos: SPNEGO probe cookie present and no Negotiate header; falling back to password form") |
| 170 | + return nil, false, nil |
| 171 | + } |
| 172 | + setSPNEGOProbeCookie(w, r) |
| 173 | + } |
| 174 | + |
| 175 | + // Run the SPNEGO middleware with a capturing recorder so we can decide |
| 176 | + // whether to forward its response or fall back to the password form. |
| 177 | + var ( |
| 178 | + id *credentials.Credentials |
| 179 | + innerErr error |
| 180 | + ) |
| 181 | + inner := http.HandlerFunc(func(_ http.ResponseWriter, rr *http.Request) { |
| 182 | + ident := goidentity.FromHTTPRequestContext(rr) |
| 183 | + if ident == nil { |
| 184 | + innerErr = fmt.Errorf("kerberos: no identity in request context after SPNEGO") |
| 185 | + return |
| 186 | + } |
| 187 | + creds, ok := ident.(*credentials.Credentials) |
| 188 | + if !ok { |
| 189 | + innerErr = fmt.Errorf("kerberos: unexpected identity type %T", ident) |
| 190 | + return |
| 191 | + } |
| 192 | + id = creds |
| 193 | + }) |
| 194 | + |
| 195 | + rec := newBufferedResponse() |
| 196 | + c.krb.authenticate(inner).ServeHTTP(rec, r) |
| 197 | + |
| 198 | + if innerErr != nil { |
| 199 | + c.logger.Error("kerberos: SPNEGO middleware completed with unusable credentials", "err", innerErr) |
| 200 | + return nil, true, innerErr |
| 201 | + } |
| 202 | + |
| 203 | + if id == nil { |
| 204 | + // Client offered a token and the middleware rejected it: under |
| 205 | + // fallback semantics this is "tried and failed" — render the form |
| 206 | + // rather than looping 401s. |
| 207 | + if c.krbConf.FallbackToPassword && hasNegotiate { |
| 208 | + c.logger.Info("kerberos: SPNEGO rejected client token; falling back to password form") |
| 209 | + return nil, false, nil |
| 210 | + } |
| 211 | + // Otherwise (fallback=false, OR fallback=true probe round): forward |
| 212 | + // the middleware-authored response (challenge token, error payload, |
| 213 | + // etc.) verbatim — the protocol decided to reject and we preserve |
| 214 | + // its wire details. |
| 215 | + c.logger.Info("kerberos: SPNEGO did not authenticate; forwarding middleware response", "status", rec.code) |
| 216 | + copyBuffered(rec, w) |
| 217 | + return nil, true, nil |
| 218 | + } |
| 219 | + |
| 220 | + if c.krbConf.ExpectedRealm != "" && !strings.EqualFold(c.krbConf.ExpectedRealm, id.Domain()) { |
| 221 | + c.logger.Info("kerberos: realm mismatch", "expected", c.krbConf.ExpectedRealm, "actual", id.Domain()) |
| 222 | + if c.krbConf.FallbackToPassword { |
| 223 | + // Intentionally do NOT clear the probe cookie here: the |
| 224 | + // client's TGT is for a realm we will never accept. Clearing |
| 225 | + // would re-arm a probe round on the next no-Authorization GET, |
| 226 | + // re-issue 401 Negotiate, the browser would resend the same |
| 227 | + // wrong-realm token, and we'd land back here — an infinite |
| 228 | + // flap. Keeping the cookie pins the client to the password |
| 229 | + // form until the cookie's Max-Age elapses. |
| 230 | + return nil, false, nil |
| 231 | + } |
| 232 | + // SPNEGO authenticated successfully, but our ExpectedRealm policy |
| 233 | + // rejects it. The middleware's buffered response is irrelevant here |
| 234 | + // (it represents the post-success path through the inner handler); |
| 235 | + // emit our own bare Negotiate challenge so the client knows to |
| 236 | + // retry with a different realm. |
| 237 | + writeBareNegotiateChallenge(w) |
| 238 | + return nil, true, nil |
| 239 | + } |
| 240 | + |
| 241 | + mapped := mapPrincipal(id.UserName(), id.Domain(), c.krbConf.UsernameFromPrincipal) |
| 242 | + c.logger.Info("kerberos: principal authenticated", |
| 243 | + "principal", id.UserName(), |
| 244 | + "realm", id.Domain(), |
| 245 | + "auth_time", id.AuthTime(), |
| 246 | + "mapped_username", mapped, |
| 247 | + ) |
| 248 | + |
| 249 | + userEntry, err := c.lookupKerberosUser(ctx, mapped) |
| 250 | + if err != nil { |
| 251 | + c.logger.Error("kerberos: LDAP user lookup failed", |
| 252 | + "principal", id.UserName(), "mapped", mapped, "err", err) |
| 253 | + return nil, true, fmt.Errorf("ldap: user lookup failed for kerberos principal %q: %w", id.UserName(), err) |
| 254 | + } |
| 255 | + c.logger.Info("kerberos: LDAP user found", "dn", userEntry.DN) |
| 256 | + |
| 257 | + ident, err := c.identityFromEntry(userEntry) |
| 258 | + if err != nil { |
| 259 | + c.logger.Error("kerberos: failed to build identity from LDAP entry", "err", err) |
| 260 | + return nil, true, err |
| 261 | + } |
| 262 | + if s.Groups { |
| 263 | + groups, err := c.groups(ctx, userEntry) |
| 264 | + if err != nil { |
| 265 | + c.logger.Error("kerberos: failed to query groups", "err", err) |
| 266 | + return nil, true, fmt.Errorf("ldap: failed to query groups: %w", err) |
| 267 | + } |
| 268 | + ident.Groups = groups |
| 269 | + } |
| 270 | + |
| 271 | + // No user-bind has happened; only materialize ConnectorData when refresh |
| 272 | + // needs the LDAP entry later (OfflineAccess). Mirror (*ldapConnector).Login: |
| 273 | + // a marshal failure here would silently break a subsequent Refresh call |
| 274 | + // (Unmarshal on nil), so fail the login instead of letting that happen. |
| 275 | + if s.OfflineAccess { |
| 276 | + refresh := refreshData{Username: mapped, Entry: userEntry} |
| 277 | + data, mErr := json.Marshal(refresh) |
| 278 | + if mErr != nil { |
| 279 | + c.logger.Error("kerberos: failed to marshal refresh data", "err", mErr) |
| 280 | + return nil, true, fmt.Errorf("ldap: marshal refresh data: %w", mErr) |
| 281 | + } |
| 282 | + ident.ConnectorData = data |
| 283 | + } |
| 284 | + |
| 285 | + // If the client carried a stale probe cookie from a prior fallback |
| 286 | + // round, clear it so a future logout/re-login starts negotiation fresh. |
| 287 | + if hasSPNEGOProbeCookie(r) { |
| 288 | + clearSPNEGOProbeCookie(w, r) |
| 289 | + } |
| 290 | + |
| 291 | + c.logger.Info("kerberos: SPNEGO login succeeded", |
| 292 | + "username", ident.Username, "email", ident.Email, "groups_count", len(ident.Groups)) |
| 293 | + return &ident, true, nil |
| 294 | +} |
| 295 | + |
| 296 | +// copyBuffered forwards a buffered handler response to the real client. |
| 297 | +// Used when we want the middleware-authored bytes to reach the user agent |
| 298 | +// unchanged (e.g. SPNEGO continuation/reject tokens). |
| 299 | +func copyBuffered(rec *bufferedResponse, w http.ResponseWriter) { |
| 300 | + for k, vv := range rec.header { |
| 301 | + for _, v := range vv { |
| 302 | + w.Header().Add(k, v) |
| 303 | + } |
| 304 | + } |
| 305 | + w.WriteHeader(rec.code) |
| 306 | + _, _ = w.Write(rec.body.Bytes()) |
| 307 | +} |
| 308 | + |
| 309 | +// writeBareNegotiateChallenge emits an unsolicited "WWW-Authenticate: |
| 310 | +// Negotiate" 401 directly to the client. This is reserved for cases where |
| 311 | +// Dex itself rejects an otherwise-successful SPNEGO exchange (currently |
| 312 | +// only ExpectedRealm mismatch); in those cases the middleware's buffered |
| 313 | +// output does not represent the answer we want to send. |
| 314 | +func writeBareNegotiateChallenge(w http.ResponseWriter) { |
| 315 | + w.Header().Set("WWW-Authenticate", "Negotiate") |
| 316 | + w.WriteHeader(http.StatusUnauthorized) |
| 317 | +} |
| 318 | + |
| 319 | +// spnegoProbeCookieName names the short-lived cookie that records "we |
| 320 | +// already issued a 401 Negotiate challenge to this client". It implements |
| 321 | +// the fallback-to-password semantics: the very first GET without an |
| 322 | +// Authorization header gets a real Negotiate challenge so a Kerberos-aware |
| 323 | +// browser can SSO; if the client comes back without a token we treat that |
| 324 | +// as "client cannot/will not negotiate" and render the password form |
| 325 | +// instead of looping 401s. The cookie is bounded by a small Max-Age so a |
| 326 | +// later visit gets a fresh chance to negotiate. |
| 327 | +const ( |
| 328 | + spnegoProbeCookieName = "dex_spnego_tried" |
| 329 | + spnegoProbeMaxAge = 60 // seconds; long enough for one challenge round-trip |
| 330 | +) |
| 331 | + |
| 332 | +func hasSPNEGOProbeCookie(r *http.Request) bool { |
| 333 | + _, err := r.Cookie(spnegoProbeCookieName) |
| 334 | + return err == nil |
| 335 | +} |
| 336 | + |
| 337 | +// newSPNEGOProbeCookie returns a probe-cookie carrier with the attributes |
| 338 | +// shared between "set" and "clear" forms (Path, HttpOnly, Secure, SameSite), |
| 339 | +// so the two callers cannot accidentally drift apart. |
| 340 | +func newSPNEGOProbeCookie(r *http.Request, value string, maxAge int) *http.Cookie { |
| 341 | + return &http.Cookie{ |
| 342 | + Name: spnegoProbeCookieName, |
| 343 | + Value: value, |
| 344 | + // Scope to "/" so the cookie reaches every Dex auth endpoint |
| 345 | + // regardless of issuer prefix; the cookie carries no secret and |
| 346 | + // expires within spnegoProbeMaxAge seconds, so the broad path is |
| 347 | + // not a privacy or security boundary concern. |
| 348 | + Path: "/", |
| 349 | + MaxAge: maxAge, |
| 350 | + HttpOnly: true, |
| 351 | + Secure: isSecureRequest(r), |
| 352 | + SameSite: http.SameSiteLaxMode, |
| 353 | + } |
| 354 | +} |
| 355 | + |
| 356 | +func setSPNEGOProbeCookie(w http.ResponseWriter, r *http.Request) { |
| 357 | + http.SetCookie(w, newSPNEGOProbeCookie(r, "1", spnegoProbeMaxAge)) |
| 358 | +} |
| 359 | + |
| 360 | +func clearSPNEGOProbeCookie(w http.ResponseWriter, r *http.Request) { |
| 361 | + http.SetCookie(w, newSPNEGOProbeCookie(r, "", -1)) |
| 362 | +} |
| 363 | + |
| 364 | +// isSecureRequest reports whether the original client connection is HTTPS. |
| 365 | +// It honors X-Forwarded-Proto so the cookie's Secure attribute is correct |
| 366 | +// when Dex sits behind a TLS-terminating proxy. The header is treated as |
| 367 | +// advisory: misbehaving/spoofed values can only flip Secure to true on a |
| 368 | +// plain-HTTP setup (which makes browsers refuse to echo the cookie back — |
| 369 | +// a graceful no-op for the probe), never to false on a real HTTPS link. |
| 370 | +func isSecureRequest(r *http.Request) bool { |
| 371 | + if r.TLS != nil { |
| 372 | + return true |
| 373 | + } |
| 374 | + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") |
| 375 | +} |
| 376 | + |
| 377 | +// lookupKerberosUser resolves an LDAP user entry by username. When |
| 378 | +// krbLookupUserHook is non-nil it is used exclusively and the real LDAP |
| 379 | +// search is skipped entirely — this keeps unit tests hermetic. |
| 380 | +func (c *ldapConnector) lookupKerberosUser(ctx context.Context, username string) (ldap.Entry, error) { |
| 381 | + if c.krbLookupUserHook != nil { |
| 382 | + return c.krbLookupUserHook(ctx, c, username) |
| 383 | + } |
| 384 | + |
| 385 | + var userEntry ldap.Entry |
| 386 | + err := c.do(ctx, func(conn *ldap.Conn) error { |
| 387 | + entry, found, err := c.userEntry(conn, username) |
| 388 | + if err != nil { |
| 389 | + return err |
| 390 | + } |
| 391 | + if !found { |
| 392 | + return fmt.Errorf("user not found for principal") |
| 393 | + } |
| 394 | + userEntry = entry |
| 395 | + return nil |
| 396 | + }) |
| 397 | + return userEntry, err |
| 398 | +} |
0 commit comments