Skip to content

Commit 3e1228a

Browse files
committed
feat: Add Kerberos support
Signed-off-by: Ivan Zvyagintsev <ivan.zvyagintsev@flant.com>
1 parent 13f012f commit 3e1228a

9 files changed

Lines changed: 1120 additions & 1 deletion

File tree

connector/ldap/kerberos.go

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

Comments
 (0)