Files
ward/kerberos.go
2026-03-02 15:19:32 +01:00

82 lines
2.8 KiB
Go

package main
import (
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
goidentity "github.com/jcmturner/goidentity/v6"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/jcmturner/gokrb5/v8/service"
"github.com/jcmturner/gokrb5/v8/spnego"
)
// KerberosAuth validates Kerberos SPNEGO tokens using a service keytab.
//
// The client must obtain a service ticket for the SPN (default: HTTP/<fqdn>@REALM)
// and present it via the standard "Authorization: Negotiate <base64>" header.
// Any GSSAPI-aware client works: curl --negotiate, kinit + curl, python-requests-gssapi, etc.
type KerberosAuth struct {
kt *keytab.Keytab
spn string // e.g. "HTTP/k3s.example.com" — empty = auto-select from ticket SName
}
// NewKerberosAuth loads the keytab from keytabPath.
// spn is written into the service settings so gokrb5 validates the correct principal;
// pass an empty string to accept any principal present in the keytab.
func NewKerberosAuth(keytabPath, spn string) (*KerberosAuth, error) {
kt, err := keytab.Load(keytabPath)
if err != nil {
return nil, fmt.Errorf("loading keytab %s: %w", keytabPath, err)
}
return &KerberosAuth{kt: kt, spn: spn}, nil
}
// Authenticate validates the SPNEGO token from the request's Authorization header.
// On success it returns the authenticated username (principal primary, without @REALM).
// On failure it sets a WWW-Authenticate: Negotiate header on w (for mutual-auth
// continuation tokens) and returns an error; the caller is responsible for the 401.
func (k *KerberosAuth) Authenticate(w http.ResponseWriter, r *http.Request) (string, error) {
if !strings.HasPrefix(r.Header.Get("Authorization"), "Negotiate ") {
return "", fmt.Errorf("no Negotiate token in request")
}
// We use gokrb5's SPNEGO middleware but call it synchronously via a
// closure — ServeHTTP is not goroutine-concurrent so the captured
// variables are safe to read after the call returns.
var (
authed bool
username string
)
inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
id := goidentity.FromHTTPRequestContext(r)
if id != nil && id.Authenticated() {
authed = true
username = id.UserName()
}
})
var opts []func(*service.Settings)
if k.spn != "" {
opts = append(opts, service.KeytabPrincipal(k.spn))
}
opts = append(opts, service.DecodePAC(false))
opts = append(opts, service.Logger(log.Default()))
// Route through a recorder so we can capture the WWW-Authenticate
// continuation token (mutual authentication) without writing it yet.
rec := httptest.NewRecorder()
spnego.SPNEGOKRB5Authenticate(inner, k.kt, opts...).ServeHTTP(rec, r)
if !authed {
// Forward any Negotiate continuation token from the middleware.
if v := rec.Header().Get("WWW-Authenticate"); v != "" {
w.Header().Set("WWW-Authenticate", v)
}
return "", fmt.Errorf("kerberos authentication failed")
}
return username, nil
}