Files
2026-06-23 17:59:40 +02:00

93 lines
3.6 KiB
Go

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
}