feat(api): authentication — login, two-step TOTP, invites and resets

Sessions are stored as hashes, so a database dump contains nothing that
can be pasted into a browser. A login that has passed the password but
not the second factor is modelled as a pending session: it expires, is
revocable, and reaches exactly one endpoint.

Signup is invite-only and takes the email address from the invite rather
than the form, so a link cannot be redirected to somebody else. The
first admin is created by an endpoint that refuses once any account
exists, checked and inserted in one transaction because it is
necessarily unauthenticated.

Login failures and forgotten-password requests answer identically
whether or not the address has an account — otherwise the form is an
account-existence oracle. Password rules are a length floor and nothing
else: composition rules push people towards worse passwords.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-16 23:31:29 +02:00
co-authored by Claude Opus 5
parent 8251e69155
commit 1bb644f4d1
15 changed files with 2943 additions and 10 deletions
+208
View File
@@ -0,0 +1,208 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/store"
)
type ctxKey int
const (
ctxUser ctxKey = iota
ctxSession
)
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
// enforced by the browser: it refuses the cookie unless it is Secure, path=/ and
// has no Domain — which means a subdomain cannot set a cookie that overwrites this
// one. Over plain HTTP the prefix is invalid, so a local dev instance uses the
// bare name.
const (
sessionCookieSecure = "__Host-verdande_session"
sessionCookieInsecure = "verdande_session"
)
func (s *Server) cookieName() string {
if s.secureCookies() {
return sessionCookieSecure
}
return sessionCookieInsecure
}
func (s *Server) secureCookies() bool {
return strings.HasPrefix(s.cfg.BaseURL, "https://")
}
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: s.cookieName(),
Value: token,
Path: "/",
// httpOnly: the frontend never reads this, so a cross-site script that
// gets to run cannot exfiltrate the session.
HttpOnly: true,
Secure: s.secureCookies(),
// Lax rather than Strict: Strict would mean following an invite link from
// an email lands you logged out, which looks exactly like a broken invite.
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(ttl),
MaxAge: int(ttl.Seconds()),
})
}
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: s.cookieName(),
Value: "",
Path: "/",
HttpOnly: true,
Secure: s.secureCookies(),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// requireAuth rejects anything without a complete login.
//
// A session still waiting on its TOTP code is refused here with totp_required, so
// the two-step login cannot be skipped by simply calling the endpoint you wanted.
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, session, err := s.authenticate(r)
if err != nil {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
return
}
if session != nil && session.PendingTOTP {
writeError(w, http.StatusUnauthorized, CodeTOTPRequired,
"this login still needs its verification code")
return
}
ctx := context.WithValue(r.Context(), ctxUser, user)
if session != nil {
ctx = context.WithValue(ctx, ctxSession, session)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requirePendingSession guards the second step of a login, and nothing else.
func (s *Server) requirePendingSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, session, err := s.authenticate(r)
if err != nil || session == nil || !session.PendingTOTP {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "no login is in progress")
return
}
ctx := context.WithValue(r.Context(), ctxUser, user)
ctx = context.WithValue(ctx, ctxSession, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u := userFrom(r.Context()); u == nil || !u.IsAdmin {
writeError(w, http.StatusForbidden, CodeForbidden, "administrator access is required")
return
}
next.ServeHTTP(w, r)
})
}
// authenticate resolves a request to a user, by session cookie or by API token.
//
// Both are accepted everywhere. An API token belongs to a person and carries their
// permissions exactly — there is no separate service identity to reason about, and
// a script therefore cannot reach anything its owner could not.
func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, error) {
if token := bearerToken(r); token != "" && auth.IsAPIToken(token) {
user, err := s.db.UserByAPIToken(r.Context(), token)
if err != nil {
return nil, nil, err
}
return user, nil, nil
}
cookie, err := r.Cookie(s.cookieName())
if err != nil || cookie.Value == "" {
return nil, nil, errors.New("no credentials")
}
session, user, err := s.db.SessionByToken(r.Context(), cookie.Value)
if err != nil {
return nil, nil, err
}
return user, session, nil
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") {
return strings.TrimSpace(h[7:])
}
return ""
}
func userFrom(ctx context.Context) *store.User {
u, _ := ctx.Value(ctxUser).(*store.User)
return u
}
func sessionFrom(ctx context.Context) *store.Session {
s, _ := ctx.Value(ctxSession).(*store.Session)
return s
}
// requireCSRF blocks cross-site form posts.
//
// SameSite=Lax already stops the browser sending the cookie on a cross-site POST,
// which covers current browsers. This is the second lock: a state-changing request
// must either be same-origin by its own headers or carry an API token, which a
// cross-site form cannot set. Checked here rather than with a token in every form,
// because a token the frontend has to remember to include is a token it will one
// day forget.
func (s *Server) requireCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
// A bearer token cannot be attached by a cross-site form or image tag.
if bearerToken(r) != "" {
next.ServeHTTP(w, r)
return
}
// Sec-Fetch-Site is set by the browser and cannot be forged by page script.
switch r.Header.Get("Sec-Fetch-Site") {
case "same-origin", "same-site", "none":
next.ServeHTTP(w, r)
return
case "cross-site":
writeError(w, http.StatusForbidden, CodeForbidden, "cross-site request refused")
return
}
// Older clients send no Sec-Fetch-Site; fall back to comparing Origin.
if origin := r.Header.Get("Origin"); origin != "" && !s.isOwnOrigin(origin) {
writeError(w, http.StatusForbidden, CodeForbidden, "cross-site request refused")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) isOwnOrigin(origin string) bool {
if strings.EqualFold(origin, s.cfg.BaseURL) {
return true
}
// A local dev frontend on another port talks to the API directly.
return s.cfg.Dev && strings.HasPrefix(origin, "http://localhost:")
}