mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
Three-part fix to make browser and curl SPNEGO auth work end-to-end: 1. spnego-proxy: issue WWW-Authenticate: Negotiate challenge when no Authorization header is present so Chrome (with AuthServerAllowlist) and curl --negotiate automatically present Kerberos tokens. Previously the proxy only validated tokens if the client proactively sent them. Pass-through preserved for non-Negotiate schemes (Basic/token) so git CLI users with PATs continue to work via Gitea own auth. 2. gitea_spnego_keytab.yml: new Ansible playbook that provisions the gitea-http AD account (AES-only, msDS-SupportedEncryptionTypes=24), registers SPN HTTP/git.prole.org, resets the password to derive fresh AES keys, exports the domain keytab, and rekeys it to principal name HTTP/git.prole.org@PROLE.ORG that gokrb5 needs for keytab lookup. Key lesson: samba-tool exportkeytab --principal=HTTP/... returns empty; must export full domain keytab and rekey in Python. 3. init_gitea.sh: add setup_gitea_spnego() calling the Ansible playbook in k3s mode as part of the standard deploy flow, with inline notes on every non-obvious constraint discovered during this work. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
112 lines
3.7 KiB
Go
112 lines
3.7 KiB
Go
package main
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
goidentity "github.com/jcmturner/goidentity/v6"
|
|
"github.com/jcmturner/gokrb5/v8/keytab"
|
|
"github.com/jcmturner/gokrb5/v8/spnego"
|
|
)
|
|
|
|
const (
|
|
upstreamURL = "http://gitea-http.gitea.svc.cluster.local:3000"
|
|
listenAddr = ":4000"
|
|
)
|
|
|
|
// statusCapture wraps http.ResponseWriter to capture the HTTP status code
|
|
// written by a downstream handler so callers can inspect it after ServeHTTP.
|
|
type statusCapture struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (s *statusCapture) WriteHeader(code int) {
|
|
s.status = code
|
|
s.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func main() {
|
|
keytabPath := os.Getenv("KRB5_KTNAME")
|
|
if keytabPath == "" {
|
|
keytabPath = "/etc/krb5/http.keytab"
|
|
}
|
|
|
|
kt, err := keytab.Load(keytabPath)
|
|
if err != nil {
|
|
log.Fatalf("failed to load keytab %s: %v", keytabPath, err)
|
|
}
|
|
|
|
upstream, _ := url.Parse(upstreamURL)
|
|
proxy := httputil.NewSingleHostReverseProxy(upstream)
|
|
proxy.Director = func(req *http.Request) {
|
|
req.URL.Scheme = upstream.Scheme
|
|
req.URL.Host = upstream.Host
|
|
if _, ok := req.Header["User-Agent"]; !ok {
|
|
req.Header.Set("User-Agent", "")
|
|
}
|
|
}
|
|
|
|
// authedInner runs only when SPNEGO negotiation succeeds.
|
|
// It extracts the client username from the request context and injects
|
|
// X-WEBAUTH-USER for Gitea reverse-proxy auto-login.
|
|
authedInner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// goidentity.AddToHTTPRequestContext is called by SPNEGOKRB5Authenticate on success.
|
|
// FromHTTPRequestContext retrieves the identity set there.
|
|
if id := goidentity.FromHTTPRequestContext(r); id != nil {
|
|
username := strings.SplitN(id.UserName(), "@", 2)[0]
|
|
r.Header.Set("X-WEBAUTH-USER", username)
|
|
log.Printf("SPNEGO auth OK: %s -> %s", id.UserName(), username)
|
|
}
|
|
proxy.ServeHTTP(w, r)
|
|
})
|
|
|
|
spnegoHandler := spnego.SPNEGOKRB5Authenticate(authedInner, kt)
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/_healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(auth, "Negotiate ") {
|
|
// Client is presenting a Kerberos ticket — run full SPNEGO negotiation.
|
|
// On success: X-WEBAUTH-USER is set by authedInner, Gitea auto-logs in.
|
|
// On failure: gokrb5 returns 401 (invalid ticket — correct to reject).
|
|
rw := &statusCapture{ResponseWriter: w}
|
|
spnegoHandler.ServeHTTP(rw, r)
|
|
if rw.status == http.StatusUnauthorized {
|
|
log.Printf("SPNEGO auth FAILED: from=%s host=%s path=%s token-prefix=%.20s",
|
|
r.RemoteAddr, r.Host, r.URL.Path,
|
|
auth[len("Negotiate "):])
|
|
}
|
|
return
|
|
}
|
|
if auth != "" {
|
|
// Other auth scheme (Basic, token) — pass through to Gitea.
|
|
// This allows git CLI users with personal access tokens or passwords to
|
|
// authenticate directly via Gitea's own credential validation.
|
|
log.Printf("pass-through auth: from=%s scheme=%.6s...", r.RemoteAddr, auth)
|
|
proxy.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// No auth header — issue a SPNEGO challenge.
|
|
// Browsers with Chrome SPNEGO policy (AuthServerAllowlist=*.prole.org) will
|
|
// automatically respond with Authorization: Negotiate <kerberos-token>.
|
|
// git CLI users with PATs will retry with Basic auth (handled above).
|
|
w.Header().Set("WWW-Authenticate", "Negotiate")
|
|
http.Error(w, "Authentication required (SPNEGO/Kerberos)", http.StatusUnauthorized)
|
|
log.Printf("SPNEGO challenge issued: from=%s host=%s path=%s", r.RemoteAddr, r.Host, r.URL.Path)
|
|
})
|
|
|
|
log.Printf("spnego-proxy listening on %s, upstream %s", listenAddr, upstreamURL)
|
|
log.Fatal(http.ListenAndServe(listenAddr, mux))
|
|
}
|