package handler import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "fmt" "log/slog" "net/http" "strconv" "strings" "time" "golang.org/x/crypto/bcrypt" "vidarchive/internal/service" ) // Authentication is mandatory: there is no disabled path, and the server refuses // to start without credentials. The session is a signed cookie, not server-side // state. const sessionCookie = "session" const sessionTTL = 30 * 24 * time.Hour // maxLoginCost bounds bcrypt's work factor. bcrypt itself only refuses a cost // outside 4..31, and the top of that range takes hours per attempt. Cost 15 is // roughly two seconds, the most a login can take and still fit in loginWait. const maxLoginCost = 15 // /login is public and bcrypt is deliberately slow, so without a cap an // unauthenticated flood pins every core. loginWait bounds the queueing rather // than parking a goroutine indefinitely. const ( maxConcurrentLogins = 2 loginWait = 3 * time.Second ) // maxLoginBody is generous for two form fields, and stops a large body from // being read into memory before the credentials are even looked at. const maxLoginBody = 4 << 10 // sessionKey mixes in the stored secret so Logout can rotate it and make every // cookie issued so far stop verifying. func (h *Handler) sessionKey() []byte { h.sessionMu.RLock() secret := h.sessionSecret h.sessionMu.RUnlock() sum := sha256.Sum256([]byte(h.cfg.Username + ":" + h.cfg.PasswordHash + ":" + secret)) return sum[:] } // signExpiry binds a session to its expiry, which stops a client from extending // its own session. func (h *Handler) signExpiry(exp int64) string { mac := hmac.New(sha256.New, h.sessionKey()) fmt.Fprintf(mac, "%d", exp) return hex.EncodeToString(mac.Sum(nil)) } func (h *Handler) issueSession(w http.ResponseWriter) { exp := time.Now().Add(sessionTTL).Unix() http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: fmt.Sprintf("%d|%s", exp, h.signExpiry(exp)), Path: "/", MaxAge: int(sessionTTL.Seconds()), HttpOnly: true, Secure: h.cfg.IsHTTPS(), SameSite: http.SameSiteLaxMode, }) } func (h *Handler) clearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: h.cfg.IsHTTPS(), SameSite: http.SameSiteLaxMode, }) } func (h *Handler) hasSession(r *http.Request) bool { c, err := r.Cookie(sessionCookie) if err != nil { return false } rawExp, mac, ok := strings.Cut(c.Value, "|") if !ok { return false } exp, err := strconv.ParseInt(rawExp, 10, 64) if err != nil { return false } if !hmac.Equal([]byte(mac), []byte(h.signExpiry(exp))) { return false } return time.Now().Unix() < exp } func publicPath(path string) bool { return path == "/login" || path == "/healthz" || strings.HasPrefix(path, "/static/") } // RequireAuth is a single middleware rather than per-route wrapping, so a new // route is protected by default. func (h *Handler) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if publicPath(r.URL.Path) || h.hasSession(r) { next.ServeHTTP(w, r) return } http.Redirect(w, r, "/login", http.StatusSeeOther) }) } func (h *Handler) LoginForm(w http.ResponseWriter, r *http.Request) { if h.hasSession(r) { http.Redirect(w, r, "/", http.StatusSeeOther) return } h.renderWithRequest(w, r, "login", PageData{Title: "Sign in", ActiveTab: "login"}) } func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody) if err := r.ParseForm(); err != nil { http.Error(w, "Invalid form", http.StatusBadRequest) return } // Take a slot before hashing, so the work is bounded no matter how many // requests arrive. select { case h.loginSem <- struct{}{}: defer func() { <-h.loginSem }() case <-time.After(loginWait): flashError(w, "Too many sign-in attempts right now. Please try again.") http.Redirect(w, r, "/login", http.StatusSeeOther) return } // Verify the password even when the username is wrong, so a valid username // can't be identified by how fast the request comes back. userOK := subtle.ConstantTimeCompare([]byte(r.FormValue("username")), []byte(h.cfg.Username)) == 1 passOK := VerifyPassword(h.cfg.PasswordHash, r.FormValue("password")) if !userOK || !passOK { flashError(w, "Wrong username or password.") http.Redirect(w, r, "/login", http.StatusSeeOther) return } h.issueSession(w) http.Redirect(w, r, "/", http.StatusSeeOther) } // Logout rotates the signing secret, so a copy of the cookie taken beforehand // stops working too. There is one account, so invalidating every session is the // intent. func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { h.clearSession(w) if err := h.rotateSessionSecret(); err != nil { // This browser is signed out either way; only a copy taken elsewhere // survives. slog.Error("failed to rotate the session secret; cookies issued earlier stay valid", "err", err) } http.Redirect(w, r, "/login", http.StatusSeeOther) } func (h *Handler) rotateSessionSecret() error { secret, err := newSessionSecret() if err != nil { return err } if err := h.settingsSvc.SetSessionSecret(secret); err != nil { return err } h.sessionMu.Lock() h.sessionSecret = secret h.sessionMu.Unlock() return nil } func newSessionSecret() (string, error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return hex.EncodeToString(b[:]), nil } // loadSessionSecret creates a secret on first run. Persisting it lets sessions // survive a restart. func loadSessionSecret(settingsSvc *service.SettingsService) (string, error) { secret, err := settingsSvc.GetSessionSecret() if err != nil { return "", err } if secret != "" { return secret, nil } if secret, err = newSessionSecret(); err != nil { return "", err } return secret, settingsSvc.SetSessionSecret(secret) } // ValidatePasswordHash runs at startup too, so a malformed or absurdly expensive // hash fails there instead of turning into a login that never returns. func ValidatePasswordHash(encoded string) error { cost, err := bcrypt.Cost([]byte(encoded)) if err != nil { return fmt.Errorf("not a bcrypt hash: %w", err) } if cost > maxLoginCost { return fmt.Errorf("bcrypt cost %d is above the usable maximum %d", cost, maxLoginCost) } return nil } // VerifyPassword compares in constant time. Re-validating the hash is what // bounds the work: deriving against a cost-31 hash would hold a login slot for // hours, and reading the cost header is free by comparison. func VerifyPassword(encoded, password string) bool { if ValidatePasswordHash(encoded) != nil { return false } return bcrypt.CompareHashAndPassword([]byte(encoded), []byte(password)) == nil }