- 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)
73 lines
2.3 KiB
Go
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()) }
|