NextMyDay/internal/core/httpx/csrf.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

73 lines
2.3 KiB
Go

package httpx
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"net/http"
)
// ErrNoCSRFKey is returned when the CSRF key is empty — fail closed.
var ErrNoCSRFKey = errors.New("httpx: CSRF key is required")
// CSRF derives and verifies the per-session CSRF token on every action request
// (SPEC-003 §6, A8). The token is HMAC-SHA256(session_id) under the config
// `csrf_key`, so it is bound to the session, needs no extra table, and dies
// with the session.
//
// The action routing fields (`do`/`action`) are NOT a security control on their
// own (D-017); this token is. The session cookie is HttpOnly, so a page embeds
// the token in a hidden form field via Token / the request context.
type CSRF struct {
key []byte
}
// NewCSRF builds a CSRF helper from the configured key. An empty key fails
// closed.
func NewCSRF(key []byte) (*CSRF, error) {
if len(key) == 0 {
return nil, ErrNoCSRFKey
}
k := make([]byte, len(key))
copy(k, key)
return &CSRF{key: k}, nil
}
// Token returns the CSRF token bound to sessionID.
func (c *CSRF) Token(sessionID string) string {
mac := hmac.New(sha256.New, c.key)
mac.Write([]byte("myday.csrf.v1:"))
mac.Write([]byte(sessionID))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Valid reports whether token is the correct CSRF token for sessionID. It
// rejects empty input and compares in constant time.
func (c *CSRF) Valid(sessionID, token string) bool {
if sessionID == "" || token == "" {
return false
}
want := c.Token(sessionID)
return hmac.Equal([]byte(want), []byte(token))
}
// tokenCtxKey is the unexported context key for the request's CSRF token.
type tokenCtxKey struct{}
// WithToken exposes the request's CSRF token on the context so a handler can
// embed it in a form without re-deriving it.
func WithToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, tokenCtxKey{}, token)
}
// TokenFrom returns the CSRF token attached by the door, if any.
func TokenFrom(ctx context.Context) string {
t, _ := ctx.Value(tokenCtxKey{}).(string)
return t
}
// TokenFromRequest is a convenience for handlers: the token the door attached
// to the request, ready for a hidden form field.
func TokenFromRequest(r *http.Request) string { return TokenFrom(r.Context()) }