package auth import ( "context" "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "errors" "fmt" "log/slog" "net/http" "net/netip" "strings" "sync" "time" "github.com/owen/vibedns/internal/database" "github.com/owen/vibedns/internal/models" "github.com/owen/vibedns/internal/netutil" ) // Realm is the HTTP Basic authentication realm. const Realm = "vibedns management" // PrincipalKind distinguishes an interactive administrator from automation. type PrincipalKind string const ( KindAdmin PrincipalKind = "admin" KindToken PrincipalKind = "token" ) // Principal is the authenticated identity attached to a request. type Principal struct { Name string Kind PrincipalKind TokenID int64 ClientIP string } // IsAdmin reports whether the principal is the interactive administrator. func (p Principal) IsAdmin() bool { return p.Kind == KindAdmin } type ctxKey struct{} // WithPrincipal stores a principal on a request context. func WithPrincipal(ctx context.Context, p Principal) context.Context { return context.WithValue(ctx, ctxKey{}, p) } // PrincipalFrom retrieves the principal from a request context. func PrincipalFrom(ctx context.Context) (Principal, bool) { p, ok := ctx.Value(ctxKey{}).(Principal) return p, ok } // Authenticator verifies credentials for the web UI and the REST API. type Authenticator struct { db *database.DB log *slog.Logger csrfKey []byte verifier *credentialCache attempts *attemptLimiter // trusted lists proxies whose X-Forwarded-For header we believe. trustedMu sync.RWMutex trusted *netutil.PrefixSet } // New creates an authenticator. csrfKey must be a stable secret; it is // persisted so that tokens issued before a restart stay valid. func New(db *database.DB, log *slog.Logger, csrfKey []byte) *Authenticator { return &Authenticator{ db: db, log: log, csrfKey: csrfKey, verifier: newCredentialCache(5 * time.Minute), attempts: newAttemptLimiter(10, 5*time.Minute), trusted: netutil.NewPrefixSet(nil), } } // SetTrustedProxies configures which peers may set X-Forwarded-For. func (a *Authenticator) SetTrustedProxies(cidrs []string) { a.trustedMu.Lock() a.trusted = netutil.NewPrefixSet(cidrs) a.trustedMu.Unlock() } // ClientIP resolves the client address, honouring X-Forwarded-For only when the // immediate peer is a configured trusted proxy. Trusting the header // unconditionally would let any client forge its own address and bypass the // login rate limiter. func (a *Authenticator) ClientIP(r *http.Request) string { peer, ok := netutil.AddrFromHostPort(r.RemoteAddr) if !ok { return r.RemoteAddr } a.trustedMu.RLock() trusted := a.trusted a.trustedMu.RUnlock() if trusted.Contains(peer) { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // The left-most entry is the original client. first := strings.TrimSpace(strings.Split(xff, ",")[0]) if addr, err := netip.ParseAddr(first); err == nil { return addr.Unmap().String() } } if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" { if addr, err := netip.ParseAddr(xr); err == nil { return addr.Unmap().String() } } } return peer.String() } // Errors returned by credential verification. var ( ErrUnauthorised = errors.New("authentication required") ErrLockedOut = errors.New("too many failed sign-in attempts") ErrNoAdmin = errors.New("no administrator account exists") ) // Authenticate verifies the credentials on a request. // // It accepts either HTTP Basic credentials (the interactive administrator) or // a bearer API token. Tokens are rejected for the HTML interface by the caller, // which passes allowTokens=false. func (a *Authenticator) Authenticate(r *http.Request, allowTokens bool) (Principal, error) { clientIP := a.ClientIP(r) if !a.attempts.allow(clientIP) { return Principal{}, ErrLockedOut } if allowTokens { if secret, ok := bearerToken(r); ok { p, err := a.verifyToken(r.Context(), secret, clientIP) if err != nil { a.attempts.fail(clientIP) return Principal{}, err } a.attempts.succeed(clientIP) return p, nil } } username, password, ok := r.BasicAuth() if !ok { return Principal{}, ErrUnauthorised } p, err := a.verifyPassword(r.Context(), username, password, clientIP) if err != nil { a.attempts.fail(clientIP) a.log.Warn("failed sign-in attempt", "username", username, "client", clientIP) return Principal{}, err } a.attempts.succeed(clientIP) return p, nil } func (a *Authenticator) verifyPassword(ctx context.Context, username, password, clientIP string) (Principal, error) { admin, err := a.db.Admin(ctx) if errors.Is(err, database.ErrNotFound) { return Principal{}, ErrNoAdmin } if err != nil { return Principal{}, fmt.Errorf("load administrator: %w", err) } // Compare the username in constant time so it cannot be probed by timing. userOK := subtle.ConstantTimeCompare([]byte(username), []byte(admin.Username)) == 1 // HTTP Basic sends credentials on every request, including every page load. // Running Argon2id each time would cost 64 MiB and tens of milliseconds per // request, so a successful verification is remembered briefly, keyed by a // MAC of the password rather than the password itself. if userOK && a.verifier.valid(username, password, admin.PasswordHash) { return Principal{Name: admin.Username, Kind: KindAdmin, ClientIP: clientIP}, nil } passOK, err := VerifyPassword(admin.PasswordHash, password) if err != nil { a.log.Error("stored administrator password hash is unusable", "error", err) return Principal{}, ErrUnauthorised } if !userOK || !passOK { return Principal{}, ErrUnauthorised } a.verifier.store(username, password, admin.PasswordHash) _ = a.db.TouchAdminLogin(ctx) return Principal{Name: admin.Username, Kind: KindAdmin, ClientIP: clientIP}, nil } func (a *Authenticator) verifyToken(ctx context.Context, secret, clientIP string) (Principal, error) { prefix, err := TokenPrefix(secret) if err != nil { return Principal{}, ErrUnauthorised } candidates, err := a.db.APITokensByPrefix(ctx, prefix) if err != nil { return Principal{}, fmt.Errorf("look up API token: %w", err) } for _, c := range candidates { if VerifyToken(c.Hash, secret) { go func(id int64) { tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = a.db.TouchAPIToken(tctx, id) }(c.ID) return Principal{Name: c.Name, Kind: KindToken, TokenID: c.ID, ClientIP: clientIP}, nil } } return Principal{}, ErrUnauthorised } func bearerToken(r *http.Request) (string, bool) { h := r.Header.Get("Authorization") if strings.HasPrefix(h, "Bearer ") { return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")), true } if v := r.Header.Get("X-API-Token"); v != "" { return strings.TrimSpace(v), true } return "", false } // InvalidateCredentials clears the verification cache. It is called after a // password change so the old password stops working immediately. func (a *Authenticator) InvalidateCredentials() { a.verifier.reset() } // --- credential cache --------------------------------------------------- type cachedCred struct { mac []byte hashSeen string expires time.Time } type credentialCache struct { mu sync.RWMutex key []byte ttl time.Duration items map[string]cachedCred } func newCredentialCache(ttl time.Duration) *credentialCache { key := make([]byte, 32) // A failure here is not fatal: an all-zero key only weakens the cache // index, which never leaves this process and is not a stored secret. if s, err := RandomKey(32); err == nil { copy(key, s) } return &credentialCache{key: key, ttl: ttl, items: map[string]cachedCred{}} } func (c *credentialCache) mac(password string) []byte { h := hmac.New(sha256.New, c.key) h.Write([]byte(password)) return h.Sum(nil) } func (c *credentialCache) valid(username, password, currentHash string) bool { c.mu.RLock() item, ok := c.items[username] c.mu.RUnlock() if !ok || time.Now().After(item.expires) { return false } // A changed stored hash means the password was rotated; the cache entry is // no longer authoritative. if item.hashSeen != currentHash { return false } return hmac.Equal(item.mac, c.mac(password)) } func (c *credentialCache) store(username, password, currentHash string) { c.mu.Lock() c.items[username] = cachedCred{ mac: c.mac(password), hashSeen: currentHash, expires: time.Now().Add(c.ttl), } c.mu.Unlock() } func (c *credentialCache) reset() { c.mu.Lock() c.items = map[string]cachedCred{} c.mu.Unlock() } // --- failed attempt limiting ------------------------------------------- type attemptState struct { failures int until time.Time last time.Time } // attemptLimiter slows down credential guessing per source address. type attemptLimiter struct { mu sync.Mutex items map[string]*attemptState max int lockout time.Duration lastGC time.Time } func newAttemptLimiter(max int, lockout time.Duration) *attemptLimiter { return &attemptLimiter{items: map[string]*attemptState{}, max: max, lockout: lockout} } func (l *attemptLimiter) allow(key string) bool { l.mu.Lock() defer l.mu.Unlock() l.gcLocked() st, ok := l.items[key] if !ok { return true } if time.Now().Before(st.until) { return false } return true } func (l *attemptLimiter) fail(key string) { l.mu.Lock() defer l.mu.Unlock() st, ok := l.items[key] if !ok { st = &attemptState{} l.items[key] = st } st.failures++ st.last = time.Now() if st.failures >= l.max { st.until = time.Now().Add(l.lockout) st.failures = 0 } } func (l *attemptLimiter) succeed(key string) { l.mu.Lock() delete(l.items, key) l.mu.Unlock() } // gcLocked drops stale entries so the map cannot grow without bound. func (l *attemptLimiter) gcLocked() { now := time.Now() if now.Sub(l.lastGC) < time.Minute { return } l.lastGC = now for k, st := range l.items { if now.After(st.until) && now.Sub(st.last) > l.lockout { delete(l.items, k) } } } // --- CSRF --------------------------------------------------------------- // CSRFCookieName is the double-submit cookie the browser echoes back. const CSRFCookieName = "vibedns_csrf" // CSRFFieldName is the form field carrying the token. const CSRFFieldName = "_csrf" // CSRFHeaderName is the header carrying the token for fetch() calls. const CSRFHeaderName = "X-CSRF-Token" const csrfTokenTTL = 12 * time.Hour // IssueCSRFToken mints a token bound to a user and an expiry. // // HTTP Basic credentials are replayed by the browser on every request, // including cross-site form posts, so Basic auth alone does not protect // state-changing requests. The token is signed, tied to the account, and // double-submitted: an attacker on another origin can neither read the cookie // nor forge the signature. func (a *Authenticator) IssueCSRFToken(username string) string { expiry := time.Now().Add(csrfTokenTTL).Unix() payload := fmt.Sprintf("%s|%d", username, expiry) mac := a.csrfMAC(payload) return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + mac)) } func (a *Authenticator) csrfMAC(payload string) string { h := hmac.New(sha256.New, a.csrfKey) h.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) } // ValidateCSRFToken checks a token's signature, expiry and account binding. func (a *Authenticator) ValidateCSRFToken(token, username string) bool { raw, err := base64.RawURLEncoding.DecodeString(token) if err != nil { return false } parts := strings.Split(string(raw), "|") if len(parts) != 3 { return false } payload := parts[0] + "|" + parts[1] if !hmac.Equal([]byte(a.csrfMAC(payload)), []byte(parts[2])) { return false } if parts[0] != username { return false } var expiry int64 if _, err := fmt.Sscanf(parts[1], "%d", &expiry); err != nil { return false } return time.Now().Unix() < expiry } // SetCSRFCookie writes the double-submit cookie. func SetCSRFCookie(w http.ResponseWriter, r *http.Request, token string) { http.SetCookie(w, &http.Cookie{ Name: CSRFCookieName, Value: token, Path: "/", HttpOnly: false, // the page's JavaScript reads it for fetch() calls Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, MaxAge: int(csrfTokenTTL / time.Second), }) } // CheckCSRF validates a state-changing browser request. // // The submitted token must be present, correctly signed for this account, and // identical to the cookie value. func (a *Authenticator) CheckCSRF(r *http.Request, p Principal) error { // API tokens are not sent automatically by browsers, so a request // authenticated by one cannot be cross-site forged. if p.Kind == KindToken { return nil } switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: return nil } submitted := r.Header.Get(CSRFHeaderName) if submitted == "" { submitted = r.PostFormValue(CSRFFieldName) } if submitted == "" { return errors.New("this request is missing its CSRF token; reload the page and try again") } cookie, err := r.Cookie(CSRFCookieName) if err != nil || cookie.Value == "" { return errors.New("the CSRF cookie is missing; make sure cookies are enabled, then reload the page") } if subtle.ConstantTimeCompare([]byte(submitted), []byte(cookie.Value)) != 1 { return errors.New("the CSRF token does not match; reload the page and try again") } if !a.ValidateCSRFToken(submitted, p.Name) { return errors.New("the CSRF token has expired; reload the page and try again") } return nil } // EnsureAdmin creates the administrator account if one does not exist, // returning the generated password when it had to invent one. func (a *Authenticator) EnsureAdmin(ctx context.Context, username, password string) (created bool, generated string, err error) { if _, err := a.db.Admin(ctx); err == nil { return false, "", nil } else if !errors.Is(err, database.ErrNotFound) { return false, "", err } mustChange := false if password == "" { password, err = GeneratePassword(20) if err != nil { return false, "", err } generated = password mustChange = true } hash, err := HashPassword(password) if err != nil { return false, "", err } if err := a.db.CreateAdmin(ctx, username, hash, mustChange); err != nil { return false, "", err } return true, generated, nil } // Admin returns the administrator record. func (a *Authenticator) Admin(ctx context.Context) (models.Admin, error) { return a.db.Admin(ctx) }