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

126 lines
3.7 KiB
Go

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
})
}