package server import ( "crypto/rand" "crypto/sha256" "encoding/hex" "log/slog" "net/http" "sync" "time" ) // credCache memoises successful Basic-auth validations for a short TTL. Browsers // resend the Authorization header on every request, so without this each one // would trigger a full PAM round-trip (slow, and a brute-force/lockout risk). // Only positive results are cached, keyed by a salted SHA-256 of user+password // so the cache never holds a recoverable secret. A fresh random salt per process // keeps keys from being precomputable. type credCache struct { mu sync.Mutex ttl time.Duration salt []byte entries map[string]time.Time } func newCredCache(ttl time.Duration) *credCache { salt := make([]byte, 16) _, _ = rand.Read(salt) return &credCache{ttl: ttl, salt: salt, entries: map[string]time.Time{}} } func (c *credCache) key(user, pass string) string { h := sha256.New() h.Write(c.salt) h.Write([]byte(user)) h.Write([]byte{0}) h.Write([]byte(pass)) return hex.EncodeToString(h.Sum(nil)) } // valid reports whether user/pass was validated within the TTL. func (c *credCache) valid(user, pass string) bool { if c.ttl <= 0 { return false } k := c.key(user, pass) c.mu.Lock() defer c.mu.Unlock() exp, ok := c.entries[k] if !ok { return false } if time.Now().After(exp) { delete(c.entries, k) return false } return true } // store records a successful validation, opportunistically pruning expired keys. func (c *credCache) store(user, pass string) { if c.ttl <= 0 { return } k := c.key(user, pass) now := time.Now() c.mu.Lock() defer c.mu.Unlock() c.entries[k] = now.Add(c.ttl) if len(c.entries) > 1024 { for kk, exp := range c.entries { if now.After(exp) { delete(c.entries, kk) } } } } // basicAuth wraps next with HTTP Basic authentication validated by authFn (PAM, // see internal/pamauth). It mirrors kerberosAuth: a successful login writes the // username into userHeader for the existing access pipeline (accessMiddleware / // wsHandler), and any inbound value of userHeader is always discarded first so a // client cannot spoof an identity. // // challenge controls the no/invalid-credentials case: // - challenge=true (REST/page API): reply 401 + WWW-Authenticate: Basic so the // browser prompts for credentials. // - challenge=false (WebSocket upgrade): fall through unauthenticated; the // session resolves to default_user. Browsers cannot show a login dialog for a // WebSocket, but once they have cached credentials from the page's API calls // they resend them on the upgrade, so the validated path is still taken. func basicAuth(authFn func(user, pass string) error, userHeader, realm string, challenge bool, cache *credCache, log *slog.Logger, next http.Handler) http.Handler { if realm == "" { realm = "uopi" } challengeValue := `Basic realm="` + realm + `", charset="UTF-8"` return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Never trust a client-supplied identity header; only a validated login sets it. r.Header.Del(userHeader) if user, pass, ok := r.BasicAuth(); ok && user != "" { if cache.valid(user, pass) { r.Header.Set(userHeader, user) next.ServeHTTP(w, r) return } if err := authFn(user, pass); err == nil { cache.store(user, pass) r.Header.Set(userHeader, user) next.ServeHTTP(w, r) return } else { log.Warn("basic auth failed", "user", user, "err", err) } } // No or invalid credentials. if challenge { w.Header().Set("WWW-Authenticate", challengeValue) http.Error(w, "authentication required", http.StatusUnauthorized) return } next.ServeHTTP(w, r) // best-effort (WebSocket): downstream → default_user }) }