package server import ( stdlog "log" "log/slog" "net/http" "strings" "github.com/jcmturner/goidentity/v6" "github.com/jcmturner/gokrb5/v8/keytab" "github.com/jcmturner/gokrb5/v8/service" "github.com/jcmturner/gokrb5/v8/spnego" ) // internalUserHeader is the request header the built-in authentication // middlewares (Kerberos, Basic) use to hand the validated username to the // downstream access pipeline when no external TrustedUserHeader is configured. It // is always stripped from inbound requests before validation, so a client cannot // spoof it. const internalUserHeader = "X-Uopi-User" // kerberosAuth wraps next with SPNEGO/Kerberos ("Negotiate") authentication. On a // successful handshake the authenticated principal's short username (realm // stripped) is written into userHeader, so the existing access pipeline // (accessMiddleware / wsHandler, which both read userHeader) resolves identity // uniformly whether it originated from a trusted proxy header or a Kerberos // ticket. // // challenge controls behaviour when a request carries no valid Negotiate // credentials: // - challenge=true (REST/page requests): delegate to gokrb5, which replies // 401 + WWW-Authenticate: Negotiate so the browser performs SPNEGO. // - challenge=false (WebSocket upgrades): fall through unauthenticated. // Browsers cannot attach an Authorization header when opening a WebSocket; // they only send Negotiate proactively to trusted URIs. When the header is // present we still validate it, otherwise the session resolves to // default_user exactly as before. // // Any inbound value of userHeader is always discarded before validation: with // native Kerberos there is no trusted proxy stripping client-supplied headers, so // only the SPNEGO-validated identity may set it. func kerberosAuth(kt *keytab.Keytab, spn, userHeader string, challenge bool, log *slog.Logger, next http.Handler) http.Handler { opts := []func(*service.Settings){ service.Logger(stdlog.New(slogWriter{log: log}, "", 0)), } if spn != "" { opts = append(opts, service.KeytabPrincipal(spn)) } // inner runs only after a successful SPNEGO handshake: it copies the resolved // identity into userHeader and continues down the chain. inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := goidentity.FromHTTPRequestContext(r) if id == nil { http.Error(w, "kerberos: missing identity", http.StatusUnauthorized) return } user := id.UserName() if i := strings.IndexByte(user, '@'); i >= 0 { user = user[:i] } r.Header.Set(userHeader, user) next.ServeHTTP(w, r) }) validate := spnego.SPNEGOKRB5Authenticate(inner, kt, opts...) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Never trust a client-supplied identity header under native Kerberos. r.Header.Del(userHeader) negotiate := strings.HasPrefix(r.Header.Get("Authorization"), spnego.HTTPHeaderAuthResponseValueKey) if !negotiate && !challenge { // Best-effort path (WebSocket without proactive credentials): continue // unauthenticated; downstream resolves the session to default_user. next.ServeHTTP(w, r) return } // Credentials are present (validate them) or a challenge is required. validate.ServeHTTP(w, r) }) } // slogWriter adapts the std logger gokrb5 expects to slog at debug level so SPNEGO // validation diagnostics surface without polluting normal output. type slogWriter struct{ log *slog.Logger } func (s slogWriter) Write(p []byte) (int, error) { if s.log != nil { s.log.Debug("kerberos", "msg", strings.TrimRight(string(p), "\n")) } return len(p), nil }