RequireSession now stores the validated session in the request context
(auth.WithSession / auth.SessionFrom); auth.ActorFrom derives the actor
from it, so there is one context value.
Every /api/v1 request other than GET, HEAD and OPTIONS must carry
X-MealQuest-CSRF equal to the session's csrf_token (constant-time
compare), else 403 {"error":{"code":"csrf"}}. The check runs after
RequireSession, so unauthenticated requests still get 401. A session
with no token is a 500, never a match against a missing header.
GET /api/v1/me returns {name, email, household: {label: "Our kitchen"},
csrfToken} with Cache-Control: no-store.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
43 lines
1.4 KiB
Go
43 lines
1.4 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
|
)
|
|
|
|
// CSRFHeader carries the session's CSRF token, which the client reads from
|
|
// GET /api/v1/me.
|
|
const CSRFHeader = "X-MealQuest-CSRF"
|
|
|
|
// requireCSRF passes GET, HEAD and OPTIONS requests to next; every other
|
|
// request must carry CSRFHeader equal to its session's CSRF token or get 403
|
|
// csrf. It runs behind requireSession, which puts the session in the context.
|
|
func requireCSRF(logger *slog.Logger) func(http.Handler) http.Handler {
|
|
forbidden := apiErrorHandler(logger, http.StatusForbidden, "csrf", "missing or invalid CSRF token")
|
|
internal := apiErrorHandler(logger, http.StatusInternalServerError, "internal", "internal error")
|
|
return func(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
|
|
}
|
|
s, ok := auth.SessionFrom(r.Context())
|
|
if !ok || s.CSRFToken == "" {
|
|
// An empty token would match a missing header.
|
|
logger.ErrorContext(r.Context(), "csrf check without a session token", "session", ok)
|
|
internal(w, r)
|
|
return
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(r.Header.Get(CSRFHeader)), []byte(s.CSRFToken)) != 1 {
|
|
forbidden(w, r)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|