Files
BountyBoard/internal/http/session.go
T
etalon c1cc781279 feat: authentication with local accounts, sessions, CSRF, RBAC, and OIDC PKCE (phase 3)
- argon2id (t=3, m=64MiB, p=2) PHC hashing honoring embedded params
- token-bucket rate limiting (10/15min per IP+email) on login/register
- opaque 32B session tokens in Mongo, 30-day sliding expiry, logout-all
- CSRF double-submit cookie/header on authenticated mutations
- bootstrap admin from env with forced first-login password change
- requireAuth/requireRole middleware with disabled-account enforcement
- OIDC code flow with PKCE: lazy discovery, account linking only on
  verified email, auto-created developer accounts
- unit tests (RBAC matrix, CSRF, password, rate limiter) + integration
  suite covering the full auth matrix incl. an in-test fake IdP

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:31:25 +02:00

168 lines
5.4 KiB
Go

package httpx
import (
"context"
"errors"
"net/http"
"time"
"bountyboard/internal/domain"
"bountyboard/internal/store"
)
const (
sessionCookie = "bb_session"
csrfCookie = "bb_csrf"
csrfHeader = "X-CSRF-Token"
// sessions slide forward at most once per hour to avoid a write per
// request
refreshInterval = time.Hour
)
const (
ctxKeyUser ctxKey = 100 + iota
ctxKeySession
)
// CurrentUser returns the authenticated user, or nil outside requireAuth.
func CurrentUser(ctx context.Context) *domain.User {
u, _ := ctx.Value(ctxKeyUser).(*domain.User)
return u
}
// CurrentSession returns the active session, or nil outside requireAuth.
func CurrentSession(ctx context.Context) *domain.Session {
s, _ := ctx.Value(ctxKeySession).(*domain.Session)
return s
}
func (s *Server) setAuthCookies(w http.ResponseWriter, sessionToken, csrfToken string) {
secure := s.cfg.CookieSecure()
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: sessionToken, Path: "/",
MaxAge: int(domain.SessionTTL.Seconds()),
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode,
})
// Readable by JS so the frontend can mirror it into X-CSRF-Token.
http.SetCookie(w, &http.Cookie{
Name: csrfCookie, Value: csrfToken, Path: "/",
MaxAge: int(domain.SessionTTL.Seconds()),
HttpOnly: false, Secure: secure, SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) clearAuthCookies(w http.ResponseWriter) {
secure := s.cfg.CookieSecure()
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: csrfCookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: false, Secure: secure, SameSite: http.SameSiteLaxMode,
})
}
// mustChangeAllowed are the only API paths reachable while a forced password
// change is pending.
var mustChangeAllowed = map[string]bool{
"/api/v1/auth/me": true,
"/api/v1/auth/logout": true,
"/api/v1/auth/logout-all": true,
"/api/v1/auth/change-password": true,
}
// requireAuth resolves the session cookie into a user, enforces the disabled
// flag and forced password changes, slides session expiry, and stores both
// in the request context.
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
writeError(w, http.StatusUnauthorized, "unauthenticated", "authentication required")
return
}
sess, err := s.store.SessionByToken(r.Context(), c.Value)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
s.clearAuthCookies(w)
writeError(w, http.StatusUnauthorized, "unauthenticated", "session expired")
return
}
s.internalError(w, r, "load session", err)
return
}
user, err := s.store.UserByID(r.Context(), sess.UserID)
if err != nil {
s.clearAuthCookies(w)
writeError(w, http.StatusUnauthorized, "unauthenticated", "account no longer exists")
return
}
if user.Disabled {
_ = s.store.DeleteSession(r.Context(), sess.Token)
s.clearAuthCookies(w)
writeError(w, http.StatusForbidden, "account_disabled", "account is disabled")
return
}
if user.MustChangePassword() && !mustChangeAllowed[r.URL.Path] {
writeError(w, http.StatusForbidden, "password_change_required",
"password change required before continuing")
return
}
if time.Since(sess.RefreshedAt) > refreshInterval {
if err := s.store.RefreshSession(r.Context(), sess.Token); err != nil {
s.log.Warn("refresh session", "err", err)
}
}
ctx := context.WithValue(r.Context(), ctxKeyUser, user)
ctx = context.WithValue(ctx, ctxKeySession, sess)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requireCSRF enforces the double-submit check on mutating methods: the
// non-httpOnly cookie value must be echoed in the X-CSRF-Token header.
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
}
c, err := r.Cookie(csrfCookie)
if err != nil || c.Value == "" || r.Header.Get(csrfHeader) != c.Value {
writeError(w, http.StatusForbidden, "csrf", "missing or mismatched CSRF token")
return
}
next.ServeHTTP(w, r)
})
}
// requireRole gates a handler on a role flag; must run inside requireAuth.
func (s *Server) requireRole(role string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := CurrentUser(r.Context())
if u == nil || !u.Roles.Has(role) {
writeError(w, http.StatusForbidden, "forbidden", "requires "+role+" role")
return
}
next.ServeHTTP(w, r)
})
}
// authed wires the standard chain for an authenticated JSON endpoint.
func (s *Server) authed(h http.HandlerFunc) http.Handler {
return s.requireAuth(s.requireCSRF(h))
}
// authedRole wires authentication + CSRF + a role gate.
func (s *Server) authedRole(role string, h http.HandlerFunc) http.Handler {
return s.requireAuth(s.requireCSRF(s.requireRole(role, h)))
}
func (s *Server) internalError(w http.ResponseWriter, r *http.Request, what string, err error) {
s.log.Error(what, "err", err, "requestId", RequestID(r.Context()), "path", r.URL.Path)
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
}