Skip to content

Commit b3bb230

Browse files
authored
feat: Add Kerberos support (#4640)
Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
1 parent 98c0b47 commit b3bb230

9 files changed

Lines changed: 1382 additions & 1 deletion

File tree

connector/ldap/kerberos.go

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
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

Comments
 (0)