505 lines
14 KiB
Go
505 lines
14 KiB
Go
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) }
|