mitbringsl/backend/internal/httpapi/auth.go
Tronax 7b1c18590e
Backend Phase B (1/2): password auth + sessions
Argon2id password hashing (PHC format, self-encoded/decoded without an
external lib) with constant-time verification, UserStore (create/get by
email and id) and SessionStore (opaque crypto/rand tokens, SHA-256 hashed
in DB, create/lookup/revoke, last_seen_at bump on lookup).

HTTP layer: Register/Login/Logout handlers + RequireAuth middleware.
Login uses a dummy-hash path so unknown-email and wrong-password yield the
same timing/shape, narrowing user enumeration. Tokens accepted via Bearer
header (native clients) or session cookie (HttpOnly, SameSite=Lax).

Routes wired in api.go: POST /auth/register, /auth/login, /auth/logout.
Verified with go test, go vet and an end-to-end smoke test against a real
PostgreSQL container (register/login/logout/duplicate/short-pw/wrong-pw).

OIDC (Phase B part 2) follows next; the issueSession helper is reused.
2026-08-05 19:05:07 +02:00

261 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/mitbringsl/backend/internal/auth"
"github.com/mitbringsl/backend/internal/config"
)
// passwordMinLen is the minimum acceptable password length for new accounts.
const passwordMinLen = 8
// AuthHandler exposes the email/password + session endpoints. OIDC is added in
// Phase B part 2.
type AuthHandler struct {
users *auth.UserStore
sessions *auth.SessionStore
cfg *config.Config
}
// NewAuthHandler constructs an AuthHandler from the configured stores.
func NewAuthHandler(users *auth.UserStore, sessions *auth.SessionStore, cfg *config.Config) *AuthHandler {
return &AuthHandler{users: users, sessions: sessions, cfg: cfg}
}
// --- request / response bodies ----------------------------------------------
type registerRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type authResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
User userDTO `json:"user"`
}
type userDTO struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name,omitempty"`
}
// --- handlers ---------------------------------------------------------------
// Register creates a new email/password account and immediately issues a session.
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if !decodeJSON(w, r, &req) {
return
}
email := strings.TrimSpace(strings.ToLower(req.Email))
if !isValidEmail(email) {
renderError(w, http.StatusBadRequest, "Invalid email", "A valid email address is required.")
return
}
if len(req.Password) < passwordMinLen {
renderError(w, http.StatusBadRequest, "Password too short",
"Password must be at least 8 characters.")
return
}
hash, err := auth.HashPassword(req.Password, auth.DefaultArgon2Params)
if err != nil {
slog.Error("hash password failed", "error", err)
renderError(w, http.StatusInternalServerError, "Internal error", "Could not hash password.")
return
}
user, err := h.users.CreateUser(r.Context(), email, hash, strings.TrimSpace(req.DisplayName))
if err != nil {
if errors.Is(err, auth.ErrEmailTaken) {
renderError(w, http.StatusConflict, "Email already registered", err.Error())
return
}
slog.Error("create user failed", "error", err)
renderError(w, http.StatusInternalServerError, "Internal error", "Could not create user.")
return
}
h.issueSession(w, r, user, http.StatusCreated)
}
// Login verifies credentials and issues a session. Uses a constant-shape error
// path so a wrong password and an unknown email yield the same response.
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if !decodeJSON(w, r, &req) {
return
}
email := strings.TrimSpace(strings.ToLower(req.Email))
// Pre-hash a dummy so the timing path is similar regardless of user existence.
dummyHash := dummyHashForTiming()
user, err := h.users.GetUserByEmail(r.Context(), email)
realHash := ""
if err == nil && user.PasswordHash != nil {
realHash = *user.PasswordHash
}
compareHash := realHash
if compareHash == "" {
compareHash = dummyHash
}
verifyErr := auth.VerifyPassword(req.Password, compareHash)
if err != nil || user.PasswordHash == nil || verifyErr != nil {
_ = auth.VerifyPassword(req.Password, dummyHash) // absorb dummy cost
renderError(w, http.StatusUnauthorized, "Invalid credentials", "Email or password is incorrect.")
return
}
h.issueSession(w, r, user, http.StatusOK)
}
// Logout revokes the caller's current session. Always returns 204, even if no
// session was present, so the client can treat logout as best-effort.
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if token := tokenFromRequest(r, h.cfg.SessionCookieName); token != "" {
if err := h.sessions.Revoke(r.Context(), token); err != nil {
slog.Warn("revoke session failed", "error", err)
}
}
clearSessionCookie(w, h.cfg)
w.WriteHeader(http.StatusNoContent)
}
// --- helpers ----------------------------------------------------------------
// issueSession creates a session, sets the cookie (for browsers) and writes the
// JSON body containing the raw token (for native clients like the Android app).
func (h *AuthHandler) issueSession(w http.ResponseWriter, r *http.Request, u auth.User, status int) {
token, sess, err := h.sessions.Create(r.Context(), u.ID, r.UserAgent(), clientIP(r))
if err != nil {
slog.Error("create session failed", "error", err, "user_id", u.ID)
renderError(w, http.StatusInternalServerError, "Internal error", "Could not create session.")
return
}
setSessionCookie(w, h.cfg, token, sess.ExpiresAt)
renderJSON(w, status, authResponse{
Token: token,
ExpiresAt: sess.ExpiresAt,
User: toUserDTO(u),
})
}
func toUserDTO(u auth.User) userDTO {
dto := userDTO{ID: u.ID.String(), Email: u.Email}
if u.DisplayName != nil {
dto.DisplayName = *u.DisplayName
}
return dto
}
// tokenFromRequest extracts the bearer token from the Authorization header, or
// falls back to the session cookie.
func tokenFromRequest(r *http.Request, cookieName string) string {
if h := r.Header.Get("Authorization"); h != "" {
if scheme, cred, ok := strings.Cut(h, " "); ok && strings.EqualFold(scheme, "Bearer") {
return strings.TrimSpace(cred)
}
}
if c, err := r.Cookie(cookieName); err == nil {
return c.Value
}
return ""
}
func setSessionCookie(w http.ResponseWriter, cfg *config.Config, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: cfg.SessionCookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: cfg.IsProduction(),
SameSite: http.SameSiteLaxMode,
})
}
func clearSessionCookie(w http.ResponseWriter, cfg *config.Config) {
http.SetCookie(w, &http.Cookie{
Name: cfg.SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: cfg.IsProduction(),
SameSite: http.SameSiteLaxMode,
})
}
// isValidEmail does a pragmatic shape check; full RFC validation is not worth it
// here the verification round-trip happens at the IdP / app layer.
func isValidEmail(s string) bool {
at := strings.IndexByte(s, '@')
if at <= 0 || at == len(s)-1 {
return false
}
return strings.IndexByte(s[at+1:], '.') >= 0
}
// clientIP extracts the peer IP, preferring X-Forwarded-For (Caddy is the only
// upstream in front of the backend).
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
// dummyHashForTiming returns a precomputed Argon2id PHC string used to give the
// same cost when an email is unknown, narrowing user-enumereration timing gaps.
// Generated once with DefaultArgon2Params.
var dummyHashForTiming = func() func() string {
// Generated at init so we always have a valid PHC string available.
h, err := auth.HashPassword("dummy-timing-padding-password", auth.DefaultArgon2Params)
if err != nil {
// Should never happen with a working crypto/rand; fall back to a
// syntactically valid but never-matching hash.
return func() string { return "$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }
}
return func() string { return h }
}()
// --- auth middleware --------------------------------------------------------
// RequireAuth wraps next so that it only runs for authenticated requests. On
// success the userID and sessionID are placed in the request context.
func (a *API) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := tokenFromRequest(r, a.cfg.SessionCookieName)
sess, err := a.auth.sessions.Lookup(r.Context(), token)
if err != nil {
renderError(w, http.StatusUnauthorized, "Unauthorized", "Valid session required.")
return
}
ctx := context.WithValue(r.Context(), ctxKeyUserID, sess.UserID)
ctx = context.WithValue(ctx, ctxKeySessionID, sess.ID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}