93 lines
3.6 KiB
Go
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
|
|
}
|