c1cc781279
- 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>
168 lines
5.4 KiB
Go
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")
|
|
}
|