- auth.Mount(env, key) wraps csrf.Protect with locked D-14/D-24 options - auth.LoadKeyFromEnv() reads SESSION_SECRET, hex-decodes, validates 32 bytes; fails fast on error - ui.CSRFField(token) templ component renders hidden _csrf input - Layout, LoginPage/Fragment, SignupPage/Fragment, Index all embed @ui.CSRFField(csrfToken) - Handlers thread csrf.Token(r) into every page/fragment render call - NewRouter mounts auth.Mount after ResolveSession, before all route groups (D-24) - main.go calls auth.LoadKeyFromEnv(); logs.Fatalf on missing/invalid SESSION_SECRET - SESSION_SECRET documented in .env.example with openssl rand -hex 32 instruction - go.mod: gorilla/csrf v1.7.3 (direct); prior tests updated with getCSRFToken helper - All Plan 04/05/06 tests updated to acquire and submit valid _csrf tokens
83 lines
3 KiB
Go
83 lines
3 KiB
Go
package auth
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
|
|
"github.com/gorilla/csrf"
|
|
)
|
|
|
|
// ErrCSRFKeyInvalid is returned by LoadKeyFromEnv when SESSION_SECRET is
|
|
// missing or decodes to a length other than 32 bytes.
|
|
var ErrCSRFKeyInvalid = errors.New("SESSION_SECRET must be a 64-char hex string encoding 32 bytes; generate with: openssl rand -hex 32")
|
|
|
|
// LoadKeyFromEnv reads SESSION_SECRET from the environment, hex-decodes it,
|
|
// and validates that the result is exactly 32 bytes. Returns ErrCSRFKeyInvalid
|
|
// for a missing, non-hex, or wrong-length value. The caller (cmd/web/main.go)
|
|
// should log.Fatalf on error.
|
|
func LoadKeyFromEnv() ([]byte, error) {
|
|
raw := os.Getenv("SESSION_SECRET")
|
|
if raw == "" {
|
|
return nil, ErrCSRFKeyInvalid
|
|
}
|
|
key, err := hex.DecodeString(raw)
|
|
if err != nil {
|
|
return nil, ErrCSRFKeyInvalid
|
|
}
|
|
if len(key) != 32 {
|
|
return nil, ErrCSRFKeyInvalid
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// Mount returns a gorilla/csrf middleware configured with the locked options
|
|
// from CONTEXT D-14 / D-24:
|
|
//
|
|
// - csrf.Secure(env != "dev"): sets the Secure flag on the _gorilla_csrf
|
|
// cookie in all environments except "dev" (plain-HTTP local development).
|
|
// - csrf.SameSite(csrf.SameSiteLaxMode): SameSite=Lax interim defense.
|
|
// - csrf.Path("/"): the CSRF cookie is scoped to the entire site.
|
|
// - csrf.FieldName("_csrf"): the hidden form field name (matches @ui.CSRFField).
|
|
// - csrf.RequestHeader("X-CSRF-Token"): accepted header for HTMX hx-headers usage.
|
|
//
|
|
// The middleware is mounted AFTER auth.ResolveSession and BEFORE any route
|
|
// group (D-24, Pitfall 7).
|
|
//
|
|
// When env == "dev", requests are additionally marked as plaintext HTTP via
|
|
// csrf.PlaintextHTTPRequest so gorilla/csrf skips the Referer-based origin
|
|
// check (which only applies to TLS). This allows local development and
|
|
// integration tests running over plain HTTP to function correctly.
|
|
//
|
|
// trustedOrigins is an optional list of additional trusted origins (used in
|
|
// tests to allow localhost requests without a Referer header).
|
|
// In production, pass nil — SameSite=Lax and the CSRF cookie handle the defense.
|
|
func Mount(env string, key []byte, trustedOrigins ...string) func(http.Handler) http.Handler {
|
|
opts := []csrf.Option{
|
|
csrf.Secure(env != "dev"),
|
|
csrf.SameSite(csrf.SameSiteLaxMode),
|
|
csrf.Path("/"),
|
|
csrf.FieldName("_csrf"),
|
|
csrf.RequestHeader("X-CSRF-Token"),
|
|
}
|
|
if len(trustedOrigins) > 0 {
|
|
opts = append(opts, csrf.TrustedOrigins(trustedOrigins))
|
|
}
|
|
csrfMiddleware := csrf.Protect(key, opts...)
|
|
|
|
// In dev mode, mark every request as plaintext HTTP so gorilla/csrf skips
|
|
// the Referer-based TLS origin check. This is safe: dev mode already has
|
|
// Secure=false on the cookie, and SameSite=Lax provides interim protection.
|
|
if env == "dev" {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Tag the request as plaintext HTTP before csrf.Protect sees it.
|
|
r = csrf.PlaintextHTTPRequest(r)
|
|
csrfMiddleware(next).ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
return csrfMiddleware
|
|
}
|