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>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user