diff --git a/internal/core/audit/audit.go b/internal/core/audit/audit.go new file mode 100644 index 0000000..84bab4d --- /dev/null +++ b/internal/core/audit/audit.go @@ -0,0 +1,151 @@ +// Package audit is MyDay's append-only security trail (A11). +// +// Every auth-sensitive event — bootstrap, enroll, login, logout, recovery +// request/approve/deny, revoke, role change — is recorded here, together with +// the client IP and (IP-derived, D-031) location. The trail outlives the users +// it names, so it carries no foreign keys. +// +// Spec: docs/specs/spec-004-identity-sessions.md §5. +package audit + +import ( + "context" + "database/sql" + "log/slog" + "time" +) + +// Event is one audit entry. Secrets are NEVER placed in Detail or Subject +// (A9) — those columns are for identifiers and outcomes only. +type Event struct { + // ActorUserID is the authenticated principal responsible for the event; + // 0 means anonymous/system (stored as NULL). + ActorUserID int64 + // Action is the event kind, e.g. "user.create", "identity.rotate". + Action string + // Subject is what the event acted on, e.g. "user:2" or "identity:5". + Subject string + // Detail is non-secret context, e.g. "role=manager". + Detail string + // ClientIP is the resolved client address (A6); optional. + ClientIP string + // Location is the approximate, IP-derived location — AUDIT ONLY (D-031). + Location string + // CorrelationID ties the event to its request (SPEC-003). + CorrelationID string +} + +// Audit records events. The contract is deliberately fire-and-forget — the +// audit trail must never break a request. +// +// Per ruling R-1 (SPEC-004 §5) the event is recorded WITH the request context: +// the door attaches Meta before any service call, and services write their +// audit rows through it, so actor · client IP · correlation are never lost. +type Audit interface { + Record(ctx context.Context, e Event) +} + +// Meta is the per-request audit metadata the door attaches to the +// context.Context before calling any service (SPEC-004 §5, ruling R-1; +// SPEC-003 §4). It is IDENTIFICATION and CONTEXT only — never a credential. +type Meta struct { + // ActorUserID is the authenticated principal responsible for the request; + // 0 means anonymous/system. + ActorUserID int64 + // ClientIP is the resolved client address (A6). + ClientIP string + // CorrelationID ties every event of one request together (SPEC-003 §4). + CorrelationID string +} + +// metaKey is the unexported context key for Meta; using a struct type prevents +// collisions with other packages' keys. +type metaKey struct{} + +// WithMeta attaches m to ctx. The door calls this once, before handing off to a +// module or a service. +func WithMeta(ctx context.Context, m Meta) context.Context { + return context.WithValue(ctx, metaKey{}, m) +} + +// MetaFrom returns the Meta attached to ctx, if any. +func MetaFrom(ctx context.Context) (Meta, bool) { + m, ok := ctx.Value(metaKey{}).(Meta) + return m, ok +} + +// Merge fills an event's empty context fields from the metadata on ctx, so a +// service never has to re-thread actor · client IP · correlation by hand. +// +// Explicit event values WIN — a service that knows the actor better than the +// ambient request context (e.g. the identity owner) may set it and it is kept +// (ruling R-3). +func Merge(ctx context.Context, e Event) Event { + m, ok := MetaFrom(ctx) + if !ok { + return e + } + if e.ActorUserID == 0 { + e.ActorUserID = m.ActorUserID + } + if e.ClientIP == "" { + e.ClientIP = m.ClientIP + } + if e.CorrelationID == "" { + e.CorrelationID = m.CorrelationID + } + return e +} + +// Log writes events to the audit_log table. +type Log struct { + db *sql.DB + now func() time.Time +} + +// New returns an Audit writing to db. time.Now is injectable for tests via the +// unexported field (same package). +func New(db *sql.DB) *Log { + return &Log{db: db, now: time.Now} +} + +// Record inserts one audit row. A failure is logged, never returned: an +// un-writable audit trail must not take the application down (but it is loud). +// +// The event is merged with the Meta carried on ctx first (ruling R-1), so the +// actor · client IP · correlation attached by the door reach the row. +func (l *Log) Record(ctx context.Context, e Event) { + e = Merge(ctx, e) + + var actor any + if e.ActorUserID != 0 { + actor = e.ActorUserID + } + + _, err := l.db.Exec( + `INSERT INTO audit_log + (at, actor_user_id, action, subject, detail, client_ip, location, correlation_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + l.now().UTC().Truncate(time.Second).Format(time.RFC3339), + actor, + e.Action, + nullString(e.Subject), + nullString(e.Detail), + nullString(e.ClientIP), + nullString(e.Location), + nullString(e.CorrelationID), + ) + if err != nil { + slog.Error("audit: record failed", "action", e.Action, "error", err) + } +} + +// nullString maps "" to NULL so the trail distinguishes "absent" from "empty". +func nullString(s string) any { + if s == "" { + return nil + } + return s +} + +var _ Audit = (*Log)(nil) diff --git a/internal/core/audit/audit_test.go b/internal/core/audit/audit_test.go new file mode 100644 index 0000000..25d0bd1 --- /dev/null +++ b/internal/core/audit/audit_test.go @@ -0,0 +1,155 @@ +package audit_test + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" +) + +func newDB(t *testing.T) *sql.DB { + t.Helper() + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + return db.DB +} + +func TestRecordWritesRow(t *testing.T) { + db := newDB(t) + log := audit.New(db) + + log.Record(context.Background(), audit.Event{ + ActorUserID: 7, + Action: "login", + Subject: "identity:3", + Detail: "totp", + ClientIP: "203.0.113.9", + Location: "GB", + CorrelationID: "corr-1", + }) + + var ( + actor sql.NullInt64 + action string + subject string + detail string + ip string + loc string + corr string + ) + err := db.QueryRow( + `SELECT actor_user_id, action, subject, detail, client_ip, location, correlation_id FROM audit_log`, + ).Scan(&actor, &action, &subject, &detail, &ip, &loc, &corr) + if err != nil { + t.Fatalf("query audit_log: %v", err) + } + if !actor.Valid || actor.Int64 != 7 { + t.Errorf("actor_user_id = %+v, want 7", actor) + } + if action != "login" || subject != "identity:3" || detail != "totp" { + t.Errorf("row = (%s,%s,%s)", action, subject, detail) + } + if ip != "203.0.113.9" || loc != "GB" || corr != "corr-1" { + t.Errorf("context = (%s,%s,%s)", ip, loc, corr) + } +} + +func TestRecordAnonymousActorIsNull(t *testing.T) { + db := newDB(t) + log := audit.New(db) + + log.Record(context.Background(), audit.Event{Action: "bootstrap"}) + + var actor sql.NullInt64 + if err := db.QueryRow(`SELECT actor_user_id FROM audit_log`).Scan(&actor); err != nil { + t.Fatalf("query: %v", err) + } + if actor.Valid { + t.Errorf("actor_user_id = %d, want NULL for an anonymous event", actor.Int64) + } +} + +// TestMetaRoundTrip pins the R-1 context seam: WithMeta/MetaFrom are lossless. +func TestMetaRoundTrip(t *testing.T) { + ctx := context.Background() + if _, ok := audit.MetaFrom(ctx); ok { + t.Fatal("MetaFrom(background) reported a meta, want none") + } + + want := audit.Meta{ActorUserID: 42, ClientIP: "203.0.113.5", CorrelationID: "corr-9"} + got, ok := audit.MetaFrom(audit.WithMeta(ctx, want)) + if !ok { + t.Fatal("MetaFrom(withMeta) reported none") + } + if got != want { + t.Errorf("MetaFrom = %+v, want %+v", got, want) + } +} + +// TestRecordMergesContextMeta proves the door's Meta reaches the audit row +// without the service threading it by hand (R-1); explicit event values win. +func TestRecordMergesContextMeta(t *testing.T) { + db := newDB(t) + log := audit.New(db) + + ctx := audit.WithMeta(context.Background(), audit.Meta{ + ActorUserID: 55, + ClientIP: "198.51.100.7", + CorrelationID: "corr-abc", + }) + + // The event carries no context fields — all come from the Meta. + log.Record(ctx, audit.Event{Action: "login"}) + + // This event overrides the actor explicitly — the event value must win. + log.Record(ctx, audit.Event{Action: "identity.revoke", ActorUserID: 9}) + + rows, err := db.Query(`SELECT action, actor_user_id, client_ip, correlation_id FROM audit_log ORDER BY id`) + if err != nil { + t.Fatalf("query audit_log: %v", err) + } + defer rows.Close() + + type row struct { + action string + actor sql.NullInt64 + ip sql.NullString + corr sql.NullString + } + var got []row + for rows.Next() { + var r row + if err := rows.Scan(&r.action, &r.actor, &r.ip, &r.corr); err != nil { + t.Fatalf("scan: %v", err) + } + got = append(got, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d rows, want 2", len(got)) + } + + if !got[0].actor.Valid || got[0].actor.Int64 != 55 { + t.Errorf("row 0 actor = %+v, want 55 (from Meta)", got[0].actor) + } + if got[0].ip.String != "198.51.100.7" || got[0].corr.String != "corr-abc" { + t.Errorf("row 0 context = (%q,%q), want the Meta values", got[0].ip.String, got[0].corr.String) + } + if !got[1].actor.Valid || got[1].actor.Int64 != 9 { + t.Errorf("row 1 actor = %+v, want 9 (explicit event value wins)", got[1].actor) + } + if got[1].corr.String != "corr-abc" { + t.Errorf("row 1 correlation = %q, want corr-abc", got[1].corr.String) + } +} diff --git a/internal/core/config/config.go b/internal/core/config/config.go new file mode 100644 index 0000000..52b96e7 --- /dev/null +++ b/internal/core/config/config.go @@ -0,0 +1,307 @@ +// Package config loads MyDay's configuration from `config.yaml` into a typed +// struct and validates it fail-closed (SPEC-003 §1, D-010/D-035). +// +// Settings live in the config file, not the database (D-035): infra · secrets · +// domain · mail · locale. The real `config.yaml` is gitignored (D-010); the +// committed `config.example.yaml` is used as a development fallback so a fresh +// checkout can still start (in setup mode, loopback-only). +// +// Fail-closed rule (SPEC-003 §1): in PRODUCTIVE mode (`setup: false`) the +// process refuses to start if a required secret is missing or weak. Setup mode +// tolerates the placeholder values in the example, because the operator has not +// provisioned the instance yet — and setup mode is loopback-only (D-025). +// +// Spec: docs/specs/spec-003-core-door.md §1, §7. +package config + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// FileName is the productive configuration file (gitignored — D-010). +const FileName = "config.yaml" + +// ExampleFileName is the committed template, used as a development fallback. +const ExampleFileName = "config.example.yaml" + +// Secret-length policy. The TOTP key must be exactly totpKeyLen bytes because +// it feeds AES-256-GCM (mirrors auth.KeySize); the other secrets are signing / +// derivation keys and must be at least minSecretLen bytes. +const ( + minSecretLen = 32 + totpKeyLen = 32 +) + +// Config is the typed shape of `config.yaml` / `config.example.yaml`. +type Config struct { + // Setup forces setup mode (D-026). Together with "no user exists" it makes + // the instance serve only /setup, loopback-only (D-025, SPEC-005 §3). + Setup bool `yaml:"setup"` + + // AdminRecovery is the config approval flag for admin recovery (D-033). + // Without it nothing happens (dual control). + AdminRecovery bool `yaml:"admin_recovery"` + + Server Server `yaml:"server"` + App App `yaml:"app"` + Database Database `yaml:"database"` + Secrets Secrets `yaml:"secrets"` + Mail Mail `yaml:"mail"` + Modules map[string]bool `yaml:"modules"` + // Enrollment bounds the invite window (A1). SPEC-006 refuses enrollment + // outside it. + Enrollment Enrollment `yaml:"enrollment"` + + // source is the path actually read (config.yaml or the example fallback). + source string +} + +// Server holds the listener, its public base URL, and the trusted proxies. +type Server struct { + Listen string `yaml:"listen"` + // BaseURL is used in invite / recovery links and decides whether cookies + // are Secure (https). + BaseURL string `yaml:"base_url"` + // TrustedProxies are the peers whose X-Forwarded-For is honoured (A6). + TrustedProxies []string `yaml:"trusted_proxies"` +} + +// App holds locale and timezone. +type App struct { + Locale string `yaml:"locale"` + Timezone string `yaml:"timezone"` +} + +// Database points at the SQLite file (D-012). +type Database struct { + Path string `yaml:"path"` +} + +// Secrets holds the symmetric key material (D-010). Never commit real values. +type Secrets struct { + SessionKey string `yaml:"session_key"` + CookieSecret string `yaml:"cookie_secret"` + CSRFKey string `yaml:"csrf_key"` + TOTPEncryptionKey string `yaml:"totp_encryption_key"` // A9 +} + +// Mail holds the SMTP settings used for invites and recovery. +type Mail struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + From string `yaml:"from"` +} + +// Enrollment holds the invite-window settings (A1). +type Enrollment struct { + // Window is a Go duration string, e.g. "72h". Empty defaults to 72h. + Window string `yaml:"window"` +} + +// Sentinel errors. +var ( + // ErrListenRequired is returned when server.listen is empty. + ErrListenRequired = errors.New("config: server.listen is required") + // ErrDatabaseRequired is returned when database.path is empty. + ErrDatabaseRequired = errors.New("config: database.path is required") + // ErrBaseURLRequired is returned when server.base_url is empty in + // productive mode (invite/recovery links need it). + ErrBaseURLRequired = errors.New("config: server.base_url is required in productive mode") + // ErrWeakSecret is returned when a required secret is missing or weak. + ErrWeakSecret = errors.New("config: required secret is missing or weak") + // ErrInvalidEnrollWindow is returned when enrollment.window is not a valid + // positive Go duration. + ErrInvalidEnrollWindow = errors.New("config: enrollment.window is not a valid duration") +) + +// DefaultEnrollWindow is the code default for the bounded enrollment window +// (A1) when enrollment.window is unset. +const DefaultEnrollWindow = 72 * time.Hour + +// EnrollWindow returns the configured enrollment window, defaulting to 72h. +func (c *Config) EnrollWindow() time.Duration { + d, err := time.ParseDuration(strings.TrimSpace(c.Enrollment.Window)) + if err != nil || d <= 0 { + return DefaultEnrollWindow + } + return d +} + +// Load reads and validates the configuration at path. +// +// Development fallback (SPEC-003 §1): when path is the canonical `config.yaml` +// and it does not exist, the committed `config.example.yaml` in the same +// directory is used instead. Any other missing path is an error. +func Load(path string) (*Config, error) { + used, data, err := read(path) + if err != nil { + return nil, err + } + + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) // fail-closed: an unknown key is a configuration error + + var c Config + if err := dec.Decode(&c); err != nil { + return nil, fmt.Errorf("config: parse %s: %w", used, err) + } + c.source = used + + if err := c.Validate(); err != nil { + return nil, fmt.Errorf("config: %s: %w", used, err) + } + return &c, nil +} + +// Source returns the path the configuration was actually loaded from. +func (c *Config) Source() string { return c.source } + +// Marshal renders c as the YAML document written to `config.yaml` (D-035). The +// wizard persists through it (SPEC-005 §4). The unexported source field is not +// part of the document. +func Marshal(c *Config) ([]byte, error) { + if c == nil { + return nil, errors.New("config: cannot marshal a nil config") + } + body, err := yaml.Marshal(c) + if err != nil { + return nil, fmt.Errorf("config: marshal: %w", err) + } + const header = "# MyDay configuration — written by MyDay (the /setup wizard or the Modules page, D-035).\n" + + "# Settings live HERE, not in the database. This file holds SECRETS:\n" + + "# it is gitignored — never commit it (D-010).\n\n" + return append([]byte(header), body...), nil +} + +// IsSecure reports whether the app is served over https (so cookies are Secure). +func (c *Config) IsSecure() bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.Server.BaseURL)), "https://") +} + +// GenerateSecret returns n cryptographically-random bytes, base64url-encoded +// without padding (R-6): the value carries no YAML-hostile characters and its +// encoded length is 4*ceil(n/3) — 24 bytes yield the 32 characters the TOTP +// sealer requires (auth.KeySize). +func GenerateSecret(n int) (string, error) { + if n <= 0 { + return "", errors.New("config: secret length must be positive") + } + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("config: secret entropy: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// Validate applies the fail-closed rules (SPEC-003 §1). +func (c *Config) Validate() error { + if strings.TrimSpace(c.Server.Listen) == "" { + return ErrListenRequired + } + if strings.TrimSpace(c.Database.Path) == "" { + return ErrDatabaseRequired + } + + // The enrollment window (A1) is parsed in both modes: a typo is a config + // error, never a silently-unbounded window. + if w := strings.TrimSpace(c.Enrollment.Window); w != "" { + d, err := time.ParseDuration(w) + if err != nil || d <= 0 { + return fmt.Errorf("%w: %q", ErrInvalidEnrollWindow, w) + } + } + + // Setup mode may run with placeholder secrets: the operator has not yet + // provisioned the instance, and it is loopback-only (D-025). Productive + // mode is strict. + if c.Setup { + return nil + } + + if strings.TrimSpace(c.Server.BaseURL) == "" { + return ErrBaseURLRequired + } + + for _, s := range []struct{ name, value string }{ + {"secrets.session_key", c.Secrets.SessionKey}, + {"secrets.cookie_secret", c.Secrets.CookieSecret}, + {"secrets.csrf_key", c.Secrets.CSRFKey}, + {"secrets.totp_encryption_key", c.Secrets.TOTPEncryptionKey}, + } { + if err := checkSecret(s.name, s.value, minSecretLen); err != nil { + return err + } + } + // The TOTP key feeds AES-256-GCM, so it must be exactly 32 bytes. + if n := len(c.Secrets.TOTPEncryptionKey); n != totpKeyLen { + return fmt.Errorf("%w: secrets.totp_encryption_key must be exactly %d bytes, got %d", + ErrWeakSecret, totpKeyLen, n) + } + return nil +} + +// read loads the file, falling back to config.example.yaml for the canonical +// config.yaml only. +func read(path string) (used string, data []byte, err error) { + data, err = os.ReadFile(path) + if err == nil { + return path, data, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", nil, fmt.Errorf("config: read %q: %w", path, err) + } + if filepath.Base(path) != FileName { + return "", nil, fmt.Errorf("config: read %q: %w", path, err) + } + + alt := filepath.Join(filepath.Dir(path), ExampleFileName) + data, altErr := os.ReadFile(alt) + if altErr != nil { + return "", nil, fmt.Errorf("config: read %q (and fallback %q): %w", path, alt, err) + } + return alt, data, nil +} + +// checkSecret rejects an empty, short, or placeholder secret value. +func checkSecret(name, value string, minLen int) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%w: %s is empty", ErrWeakSecret, name) + } + if len(value) < minLen { + return fmt.Errorf("%w: %s is %d bytes, need at least %d", ErrWeakSecret, name, len(value), minLen) + } + if isPlaceholder(value) { + return fmt.Errorf("%w: %s still holds a placeholder value", ErrWeakSecret, name) + } + return nil +} + +// SecretIsPlaceholder reports whether a secret value is empty or still holds +// one of the shipped placeholder markers (R-6). The /setup wizard replaces such +// values with strong generated ones, so a first boot from config.example.yaml +// persists a viable productive configuration. +func SecretIsPlaceholder(value string) bool { + return strings.TrimSpace(value) == "" || isPlaceholder(value) +} + +// isPlaceholder detects the well-known placeholder markers shipped in the +// example so a copy that was never edited fails closed in productive mode. +func isPlaceholder(value string) bool { + upper := strings.ToUpper(value) + return strings.Contains(upper, "CHANGE_ME") || + strings.Contains(upper, "CHANGEME") || + strings.Contains(upper, "PLACEHOLDER") +} diff --git a/internal/core/config/config_test.go b/internal/core/config/config_test.go new file mode 100644 index 0000000..d7b25b9 --- /dev/null +++ b/internal/core/config/config_test.go @@ -0,0 +1,313 @@ +package config_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" +) + +// writeConfig writes body to dir/name and returns the full path. +func writeConfig(t *testing.T, dir, name, body string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("write %s: %v", p, err) + } + return p +} + +const setupYAML = ` +setup: true +server: + listen: "127.0.0.1:8080" + base_url: "http://127.0.0.1:8080" + trusted_proxies: + - "127.0.0.1" +app: + locale: "en-GB" + timezone: "Europe/London" +database: + path: "./data/myday.db" +secrets: + session_key: "CHANGE_ME_32_BYTES_RANDOM" + cookie_secret: "CHANGE_ME_32_BYTES_RANDOM" + csrf_key: "CHANGE_ME_32_BYTES_RANDOM" + totp_encryption_key: "CHANGE_ME_32_BYTES_RANDOM" +mail: + host: "smtp.example.com" + port: 587 + username: "" + password: "CHANGE_ME" + from: "myday@example.com" +modules: + requests: true + expense: false +` + +const productiveYAML = ` +setup: false +server: + listen: "0.0.0.0:8080" + base_url: "https://myday.example.com" +database: + path: "./data/myday.db" +secrets: + session_key: "0123456789abcdef0123456789abcdef" + cookie_secret: "0123456789abcdef0123456789abcdef" + csrf_key: "0123456789abcdef0123456789abcdef" + totp_encryption_key: "0123456789abcdef0123456789abcdef" +` + +func TestLoadParsesTypedStruct(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, config.FileName, setupYAML) + + cfg, err := config.Load(filepath.Join(dir, config.FileName)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.Setup { + t.Error("Setup = false, want true") + } + if cfg.Server.Listen != "127.0.0.1:8080" { + t.Errorf("Listen = %q", cfg.Server.Listen) + } + if cfg.Mail.Port != 587 { + t.Errorf("Mail.Port = %d, want 587", cfg.Mail.Port) + } + if cfg.App.Timezone != "Europe/London" { + t.Errorf("Timezone = %q", cfg.App.Timezone) + } + if !cfg.Modules["requests"] || cfg.Modules["expense"] { + t.Errorf("Modules = %v", cfg.Modules) + } + if cfg.IsSecure() { + t.Error("IsSecure = true for an http base_url") + } +} + +// TestLoadFallsBackToExample covers the dev fallback: a fresh checkout has only +// the committed example. +func TestLoadFallsBackToExample(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, config.ExampleFileName, setupYAML) + + cfg, err := config.Load(filepath.Join(dir, config.FileName)) + if err != nil { + t.Fatalf("Load (fallback): %v", err) + } + if filepath.Base(cfg.Source()) != config.ExampleFileName { + t.Errorf("Source = %q, want the example fallback", cfg.Source()) + } +} + +// TestLoadNonCanonicalMissingIsError: only config.yaml gets the fallback. +func TestLoadNonCanonicalMissingIsError(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, config.ExampleFileName, setupYAML) + + if _, err := config.Load(filepath.Join(dir, "other.yaml")); err == nil { + t.Error("Load(other.yaml, missing) = nil, want an error (no fallback)") + } +} + +// TestSetupModeToleratesPlaceholders: setup mode may use the example as-is. +func TestSetupModeToleratesPlaceholders(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, config.FileName, setupYAML) + + if _, err := config.Load(filepath.Join(dir, config.FileName)); err != nil { + t.Fatalf("setup-mode load failed on placeholder secrets: %v", err) + } +} + +// TestProductiveModeRequiresStrongSecrets is the fail-closed gate (SPEC-003 §1). +func TestProductiveModeRequiresStrongSecrets(t *testing.T) { + cases := []struct { + name string + body string + want error + }{ + { + name: "placeholder secret", + body: strings.Replace(productiveYAML, + "0123456789abcdef0123456789abcdef", "CHANGE_ME_32_BYTES_RANDOM", 1), + want: config.ErrWeakSecret, + }, + { + name: "short secret", + body: strings.Replace(productiveYAML, + "0123456789abcdef0123456789abcdef", "tooshort", 1), + want: config.ErrWeakSecret, + }, + { + name: "totp key wrong length", + body: strings.Replace(productiveYAML, + `totp_encryption_key: "0123456789abcdef0123456789abcdef"`, + `totp_encryption_key: "0123456789abcdef0123456789abcdefff"`, 1), + want: config.ErrWeakSecret, + }, + { + name: "empty listen", + body: strings.Replace(productiveYAML, `listen: "0.0.0.0:8080"`, `listen: ""`, 1), + want: config.ErrListenRequired, + }, + { + name: "missing base_url", + body: strings.Replace(productiveYAML, `base_url: "https://myday.example.com"`, `base_url: ""`, 1), + want: config.ErrBaseURLRequired, + }, + { + name: "empty database path", + body: strings.Replace(productiveYAML, `path: "./data/myday.db"`, `path: ""`, 1), + want: config.ErrDatabaseRequired, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, config.FileName, tc.body) + _, err := config.Load(filepath.Join(dir, config.FileName)) + if !errors.Is(err, tc.want) { + t.Errorf("Load = %v, want %v", err, tc.want) + } + }) + } +} + +// TestSecretIsPlaceholder covers the R-6 detector the wizard uses to decide +// which secrets to generate. +func TestSecretIsPlaceholder(t *testing.T) { + cases := []struct { + value string + want bool + }{ + {"", true}, + {" ", true}, + {"CHANGE_ME_32_BYTES_RANDOM", true}, + {"change_me", true}, + {"my-PLACEHOLDER-value", true}, + {"0123456789abcdef0123456789abcdef", false}, + {"a-strong-generated-secret-value-here", false}, + } + for _, tc := range cases { + if got := config.SecretIsPlaceholder(tc.value); got != tc.want { + t.Errorf("SecretIsPlaceholder(%q) = %v, want %v", tc.value, got, tc.want) + } + } +} + +func TestProductiveModeStrongConfigLoads(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, config.FileName, productiveYAML) + + cfg, err := config.Load(filepath.Join(dir, config.FileName)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Setup { + t.Error("Setup = true, want false") + } + if !cfg.IsSecure() { + t.Error("IsSecure = false for an https base_url") + } +} + +// TestUnknownKeyFailsClosed: a typo in config.yaml must not be silently ignored. +func TestUnknownKeyFailsClosed(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, config.FileName, setupYAML+"\nnot_a_real_key: true\n") + + if _, err := config.Load(filepath.Join(dir, config.FileName)); err == nil { + t.Error("Load with an unknown key = nil, want an error (fail closed)") + } +} + +// TestEnrollWindow covers the config-driven bounded window (A1). +func TestEnrollWindow(t *testing.T) { + // Unset -> the 72h default. + if got := (&config.Config{}).EnrollWindow(); got != config.DefaultEnrollWindow { + t.Errorf("default EnrollWindow = %v, want %v", got, config.DefaultEnrollWindow) + } + // Configured. + c := &config.Config{Enrollment: config.Enrollment{Window: "12h"}} + if got := c.EnrollWindow(); got != 12*time.Hour { + t.Errorf("EnrollWindow(12h) = %v, want 12h", got) + } + + // A malformed window is a configuration error in any mode. + bad := &config.Config{ + Server: config.Server{Listen: "127.0.0.1:8080"}, + Database: config.Database{Path: "./data/myday.db"}, + Enrollment: config.Enrollment{Window: "not-a-duration"}, + } + if err := bad.Validate(); !errors.Is(err, config.ErrInvalidEnrollWindow) { + t.Errorf("Validate(bad window) = %v, want ErrInvalidEnrollWindow", err) + } +} + +// TestLoadParsesEnrollmentWindow reads the window from YAML. +func TestLoadParsesEnrollmentWindow(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, config.FileName, setupYAML+"\nenrollment:\n window: \"48h\"\n") + + cfg, err := config.Load(filepath.Join(dir, config.FileName)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.EnrollWindow(); got != 48*time.Hour { + t.Errorf("EnrollWindow = %v, want 48h", got) + } +} + +// TestMarshalRoundTrip: the wizard's persister produces a document that loads +// back through the SAME strict loader (SPEC-005 §4, D-035). A written config +// must be exactly as valid as a hand-written one. +func TestMarshalRoundTrip(t *testing.T) { + const good = "0123456789abcdef0123456789abcdef" + src := &config.Config{ + Setup: false, + Server: config.Server{ + Listen: "127.0.0.1:8080", + BaseURL: "http://127.0.0.1:8080", + TrustedProxies: []string{"127.0.0.1"}, + }, + App: config.App{Locale: "en-GB", Timezone: "Europe/London"}, + Database: config.Database{Path: "./data/myday.db"}, + Secrets: config.Secrets{ + SessionKey: good, CookieSecret: good, CSRFKey: good, TOTPEncryptionKey: good, + }, + Mail: config.Mail{Host: "smtp.example.com", Port: 587, From: "myday@example.com"}, + Modules: map[string]bool{"time": true}, + Enrollment: config.Enrollment{Window: "48h"}, + } + + data, err := config.Marshal(src) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + dir := t.TempDir() + path := writeConfig(t, dir, config.FileName, string(data)) + + got, err := config.Load(path) + if err != nil { + t.Fatalf("the marshalled config does not load back: %v", err) + } + if got.Server.BaseURL != src.Server.BaseURL || got.App.Locale != src.App.Locale || + got.App.Timezone != src.App.Timezone || got.Mail.Port != src.Mail.Port || + got.Secrets.TOTPEncryptionKey != src.Secrets.TOTPEncryptionKey { + t.Errorf("round-trip lost fields: %+v", got) + } + if got.EnrollWindow() != 48*time.Hour { + t.Errorf("round-trip EnrollWindow = %v, want 48h", got.EnrollWindow()) + } + if got.Setup { + t.Error("round-trip Setup = true, want false") + } +} diff --git a/internal/core/httpx/clientip.go b/internal/core/httpx/clientip.go new file mode 100644 index 0000000..6b3fe80 --- /dev/null +++ b/internal/core/httpx/clientip.go @@ -0,0 +1,121 @@ +package httpx + +import ( + "fmt" + "net" + "net/http" + "strings" +) + +// TrustedProxies is the parsed set of networks whose X-Forwarded-For is +// honoured (A6). An empty set means "nothing is trusted": the peer address is +// the client. +type TrustedProxies []*net.IPNet + +// ParseTrustedProxies parses the `server.trusted_proxies` entries. A bare IP +// ("127.0.0.1") becomes a single-host network (/32 or /128); a CIDR +// ("10.0.0.0/8") is used verbatim. Empty entries are skipped. +func ParseTrustedProxies(entries []string) (TrustedProxies, error) { + var tp TrustedProxies + for _, e := range entries { + e = strings.TrimSpace(e) + if e == "" { + continue + } + if strings.Contains(e, "/") { + _, n, err := net.ParseCIDR(e) + if err != nil { + return nil, fmt.Errorf("httpx: trusted proxy %q is not a valid CIDR: %w", e, err) + } + tp = append(tp, n) + continue + } + ip := net.ParseIP(e) + if ip == nil { + return nil, fmt.Errorf("httpx: trusted proxy %q is not an IP or CIDR", e) + } + bits := 32 + if ip.To4() == nil { + bits = 128 + } + tp = append(tp, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) + } + return tp, nil +} + +// Contains reports whether ip is one of the trusted proxies. +func (t TrustedProxies) Contains(ip net.IP) bool { + if ip == nil { + return false + } + for _, n := range t { + if n.Contains(ip) { + return true + } + } + return false +} + +// ClientIP resolves the client address for r using the trusted proxies, and +// SETS the request's X-Forwarded-For to that single value — it never appends to +// a client-supplied chain (A6, SPEC-003 §4). +// +// The rules: +// +// - the immediate peer (RemoteAddr) is NOT trusted → any X-Forwarded-For is +// ignored and the peer is the client (a client cannot spoof past the gate); +// - the peer IS trusted → walk X-Forwarded-For left to right and return the +// first address that is not itself a trusted proxy (the real client); if +// every hop is trusted, the last hop is returned. +// +// The resolved value is written back with Header.Set (replace), never Add. +func ClientIP(r *http.Request, trusted TrustedProxies) string { + peer := remoteIP(r.RemoteAddr) + + ip := peer + if trusted.Contains(peer) { + ip = resolveForwarded(r.Header.Get("X-Forwarded-For"), trusted, peer) + } + if ip == nil { + return "" + } + + s := ip.String() + r.Header.Set("X-Forwarded-For", s) // SET — never append (A6) + return s +} + +// resolveForwarded returns the leftmost non-trusted address in the header, +// falling back to the last hop, then to the peer. +func resolveForwarded(header string, trusted TrustedProxies, peer net.IP) net.IP { + if strings.TrimSpace(header) == "" { + return peer + } + var last net.IP + for _, part := range strings.Split(header, ",") { + ip := net.ParseIP(strings.TrimSpace(part)) + if ip == nil { + continue + } + last = ip + if !trusted.Contains(ip) { + return ip // the real client + } + } + if last != nil { + return last + } + return peer +} + +// remoteIP extracts the IP from a "host:port" RemoteAddr. +func remoteIP(addr string) net.IP { + if addr == "" { + return nil + } + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + return net.ParseIP(strings.Trim(strings.TrimSpace(host), "[]")) +} diff --git a/internal/core/httpx/cookie.go b/internal/core/httpx/cookie.go new file mode 100644 index 0000000..8e64a3d --- /dev/null +++ b/internal/core/httpx/cookie.go @@ -0,0 +1,40 @@ +// Package httpx holds the door's HTTP helpers: the session cookie, the client +// IP resolution (trusted proxies, A6), the security headers, log redaction, and +// the CSRF token (SPEC-003 §6/§8). +// +// It imports only net/http + the standard library — the door owns HTTP; the +// pure routing model (internal/core/routing) must never import this. +package httpx + +import "net/http" + +// SessionCookieName is the name of the session cookie (SPEC-003 §6). The value +// is an opaque random id; the server-side session row is authoritative. +const SessionCookieName = "myday_session" + +// SetSessionCookie writes the hardened session cookie (SPEC-003 §6): +// HttpOnly · Secure · SameSite=Lax · Path=/. The lifetime is server-side, so +// no Max-Age is set (a browser-session cookie). +func SetSessionCookie(w http.ResponseWriter, name, value string, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// ClearSessionCookie expires the session cookie (logout, or an invalid session). +func ClearSessionCookie(w http.ResponseWriter, name string, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} diff --git a/internal/core/httpx/csrf.go b/internal/core/httpx/csrf.go new file mode 100644 index 0000000..00e08ee --- /dev/null +++ b/internal/core/httpx/csrf.go @@ -0,0 +1,73 @@ +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()) } diff --git a/internal/core/httpx/headers.go b/internal/core/httpx/headers.go new file mode 100644 index 0000000..70c5f72 --- /dev/null +++ b/internal/core/httpx/headers.go @@ -0,0 +1,28 @@ +package httpx + +import "net/http" + +// ContentSecurityPolicy is the strict default CSP (SPEC-003 §4). It allows only +// same-origin assets; the PWA manifest and service worker are same-origin too. +const ContentSecurityPolicy = "default-src 'self'; " + + "img-src 'self' data:; " + + "style-src 'self'; " + + "script-src 'self'; " + + "connect-src 'self'; " + + "object-src 'none'; " + + "base-uri 'none'; " + + "form-action 'self'; " + + "frame-ancestors 'none'" + +// SetSecurityHeaders writes the baseline security headers on every response +// (SPEC-003 §4). HSTS is set only when the app is served over https (secure). +func SetSecurityHeaders(w http.ResponseWriter, secure bool) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", ContentSecurityPolicy) + if secure { + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } +} diff --git a/internal/core/httpx/httpx_test.go b/internal/core/httpx/httpx_test.go new file mode 100644 index 0000000..a6bc992 --- /dev/null +++ b/internal/core/httpx/httpx_test.go @@ -0,0 +1,217 @@ +package httpx_test + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" +) + +func TestSetAndClearSessionCookie(t *testing.T) { + rec := httptest.NewRecorder() + httpx.SetSessionCookie(rec, httpx.SessionCookieName, "abc123", true) + + cookies := rec.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("got %d cookies, want 1", len(cookies)) + } + c := cookies[0] + if c.Name != httpx.SessionCookieName || c.Value != "abc123" { + t.Errorf("cookie = %s=%s", c.Name, c.Value) + } + if !c.HttpOnly { + t.Error("cookie is not HttpOnly") + } + if !c.Secure { + t.Error("cookie is not Secure") + } + if c.SameSite != http.SameSiteLaxMode { + t.Errorf("SameSite = %v, want Lax", c.SameSite) + } + if c.Path != "/" { + t.Errorf("Path = %q, want /", c.Path) + } + + // Clearing expires it. + rec2 := httptest.NewRecorder() + httpx.ClearSessionCookie(rec2, httpx.SessionCookieName, true) + if got := rec2.Result().Cookies()[0].MaxAge; got >= 0 { + t.Errorf("cleared cookie MaxAge = %d, want negative", got) + } +} + +func TestParseTrustedProxies(t *testing.T) { + tp, err := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", ""}) + if err != nil { + t.Fatalf("ParseTrustedProxies: %v", err) + } + if !tp.Contains(parseIP(t, "127.0.0.1")) { + t.Error("127.0.0.1 not trusted") + } + if !tp.Contains(parseIP(t, "10.1.2.3")) { + t.Error("10.1.2.3 not covered by 10.0.0.0/8") + } + if tp.Contains(parseIP(t, "8.8.8.8")) { + t.Error("8.8.8.8 reported trusted") + } + + if _, err := httpx.ParseTrustedProxies([]string{"not-an-ip"}); err == nil { + t.Error("ParseTrustedProxies(bad) = nil, want error") + } +} + +func TestClientIPUntrustedPeerIgnoresXFF(t *testing.T) { + tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1"}) + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "203.0.113.9:5555" + r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8") + + got := httpx.ClientIP(r, tp) + if got != "203.0.113.9" { + t.Errorf("ClientIP = %q, want the peer (XFF from an untrusted peer must be ignored)", got) + } + // SET, never append. + if xff := r.Header.Get("X-Forwarded-For"); xff != "203.0.113.9" { + t.Errorf("X-Forwarded-For = %q, want it SET to %q", xff, "203.0.113.9") + } +} + +func TestClientIPTrustedPeerResolvesLeftmostUntrusted(t *testing.T) { + tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "127.0.0.1:5555" + r.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.5") + + got := httpx.ClientIP(r, tp) + if got != "198.51.100.7" { + t.Errorf("ClientIP = %q, want the leftmost non-trusted 198.51.100.7", got) + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "198.51.100.7" { + t.Errorf("X-Forwarded-For = %q, want it SET (replaced), not appended", xff) + } +} + +func TestClientIPAllHopsTrusted(t *testing.T) { + tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "127.0.0.1:5555" + r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2") + + if got := httpx.ClientIP(r, tp); got != "10.0.0.2" { + t.Errorf("ClientIP = %q, want the last hop 10.0.0.2", got) + } +} + +func TestRedactPath(t *testing.T) { + cases := []struct { + in string + mustGone string + }{ + {"/setup/token/SUPERSECRETTOKEN", "SUPERSECRETTOKEN"}, + {"/access/reset/SUPERSECRETTOKEN", "SUPERSECRETTOKEN"}, + {"/x/token=SUPERSECRETTOKEN", "SUPERSECRETTOKEN"}, + } + for _, tc := range cases { + got := httpx.RedactPath(tc.in) + if strings.Contains(got, tc.mustGone) { + t.Errorf("RedactPath(%q) = %q, still contains the credential", tc.in, got) + } + if !strings.Contains(got, httpx.Redacted) { + t.Errorf("RedactPath(%q) = %q, want a %s marker", tc.in, got, httpx.Redacted) + } + } + + // The GUID is public and must survive verbatim (D-001). + const guid = "AbCdEf0123456789" + if got := httpx.RedactPath("/" + guid + "/home"); got != "/"+guid+"/home" { + t.Errorf("RedactPath preserved a GUID: got %q", got) + } +} + +func TestRedactQuery(t *testing.T) { + got := httpx.RedactQuery("token=SECRET&view=open") + if strings.Contains(got, "SECRET") { + t.Errorf("RedactQuery = %q, still contains the token", got) + } + if !strings.Contains(got, "view=open") { + t.Errorf("RedactQuery = %q, want the non-sensitive param kept", got) + } +} + +func TestSetSecurityHeaders(t *testing.T) { + rec := httptest.NewRecorder() + httpx.SetSecurityHeaders(rec, false) + h := rec.Header() + if h.Get("X-Content-Type-Options") != "nosniff" { + t.Error("missing X-Content-Type-Options: nosniff") + } + if h.Get("Referrer-Policy") != "no-referrer" { + t.Error("missing Referrer-Policy") + } + if !strings.Contains(h.Get("Content-Security-Policy"), "default-src 'self'") { + t.Errorf("CSP = %q", h.Get("Content-Security-Policy")) + } + if h.Get("Strict-Transport-Security") != "" { + t.Error("HSTS set for an insecure request") + } + + rec2 := httptest.NewRecorder() + httpx.SetSecurityHeaders(rec2, true) + if rec2.Header().Get("Strict-Transport-Security") == "" { + t.Error("HSTS missing for a secure request") + } +} + +func TestCSRF(t *testing.T) { + csrf, err := httpx.NewCSRF([]byte("a-test-csrf-key-0123456789abcdef")) + if err != nil { + t.Fatalf("NewCSRF: %v", err) + } + + tok := csrf.Token("session-1") + if tok == "" { + t.Fatal("Token returned empty") + } + if !csrf.Valid("session-1", tok) { + t.Error("Valid rejected the correct token") + } + if csrf.Valid("session-2", tok) { + t.Error("Valid accepted a token bound to another session") + } + if csrf.Valid("session-1", "") { + t.Error("Valid accepted an empty token") + } + if csrf.Valid("", tok) { + t.Error("Valid accepted an empty session id") + } + if csrf.Valid("session-1", tok+"x") { + t.Error("Valid accepted a tampered token") + } + + if _, err := httpx.NewCSRF(nil); err == nil { + t.Error("NewCSRF(nil) = nil, want an error (fail closed)") + } +} + +func TestTokenContext(t *testing.T) { + ctx := httpx.WithToken(context.Background(), "tok-9") + if got := httpx.TokenFrom(ctx); got != "tok-9" { + t.Errorf("TokenFrom = %q, want tok-9", got) + } + if got := httpx.TokenFrom(context.Background()); got != "" { + t.Errorf("TokenFrom(background) = %q, want empty", got) + } +} + +func parseIP(t *testing.T, s string) (ip net.IP) { + t.Helper() + ip = net.ParseIP(s) + if ip == nil { + t.Fatalf("ParseIP(%q) = nil", s) + } + return ip +} diff --git a/internal/core/httpx/loopback.go b/internal/core/httpx/loopback.go new file mode 100644 index 0000000..97f5f98 --- /dev/null +++ b/internal/core/httpx/loopback.go @@ -0,0 +1,30 @@ +package httpx + +import ( + "net" + "strings" +) + +// IsLoopbackHost reports whether a request Host ("host" or "host:port") names a +// loopback origin. +// +// It gates the passkey nudge (SPEC-006 §8): a passkey registered at +// `127.0.0.1` binds the Relying Party ID `127.0.0.1` and is UNUSABLE at the +// public domain (O-13/D-041), so the nudge is suppressed on loopback. An +// empty/unknown host is treated as loopback — fail closed, so an origin we +// cannot classify never triggers the nudge. +func IsLoopbackHost(hostport string) bool { + host := strings.TrimSpace(hostport) + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.Trim(strings.TrimSpace(host), "[]") + if host == "" { + return true + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/core/httpx/presession.go b/internal/core/httpx/presession.go new file mode 100644 index 0000000..d02cbc9 --- /dev/null +++ b/internal/core/httpx/presession.go @@ -0,0 +1,140 @@ +package httpx + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/http" + "strings" +) + +// PreSessionCookieName is the cookie carrying the anonymous double-submit CSRF +// token (A15). It is issued on the enroll/login screens and verified on the +// anonymous actions — before any session exists (SPEC-006 §5). +const PreSessionCookieName = "myday_csrf" + +// ErrNoSigningKey is returned when the signing secret is empty — fail closed. +var ErrNoSigningKey = errors.New("httpx: signing key is required") + +// Signer signs and verifies opaque cookie values with HMAC-SHA256. It is the +// primitive behind the pre-session CSRF token and the WebAuthn ceremony state: +// both are tamper-evident without a server-side table. The key comes from +// `secrets.cookie_secret`. +type Signer struct{ key []byte } + +// NewSigner builds a Signer from the configured key. An empty key fails closed. +func NewSigner(key []byte) (*Signer, error) { + if len(key) == 0 { + return nil, ErrNoSigningKey + } + k := make([]byte, len(key)) + copy(k, key) + return &Signer{key: k}, nil +} + +// Sign returns `base64url(value).base64url(HMAC)`. Encoding the value keeps the +// "." delimiter unambiguous even when value contains one. +func (s *Signer) Sign(value string) string { + payload := base64.RawURLEncoding.EncodeToString([]byte(value)) + return payload + "." + s.mac(payload) +} + +// Unsign verifies a token and returns the original value. A tampered or +// malformed token reports ok=false. +func (s *Signer) Unsign(token string) (value string, ok bool) { + payload, sig, found := strings.Cut(token, ".") + if !found || payload == "" || sig == "" { + return "", false + } + if !hmac.Equal([]byte(sig), []byte(s.mac(payload))) { + return "", false + } + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return "", false + } + return string(raw), true +} + +// mac is the HMAC over the domain-separated payload. +func (s *Signer) mac(payload string) string { + m := hmac.New(sha256.New, s.key) + m.Write([]byte("myday.sign.v1:")) + m.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} + +// PreSessionCSRF is the signed double-submit token for the anonymous +// enroll/login forms (A15). SPEC-003 §6's session-bound CSRF does not apply +// before a session exists, so this fills the gap: the server issues a signed +// random token in an HttpOnly cookie AND embeds the same token in the form; on +// submit both must match and the signature must verify. +type PreSessionCSRF struct { + signer *Signer + secure bool +} + +// NewPreSessionCSRF builds the helper from `secrets.cookie_secret`. +func NewPreSessionCSRF(secret []byte, secure bool) (*PreSessionCSRF, error) { + s, err := NewSigner(secret) + if err != nil { + return nil, err + } + return &PreSessionCSRF{signer: s, secure: secure}, nil +} + +// Signer exposes the underlying signer so the flows can sign their own +// short-lived ceremony state with the same key. +func (p *PreSessionCSRF) Signer() *Signer { return p.signer } + +// Token mints a fresh signed token. +func (p *PreSessionCSRF) Token() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("httpx: csrf entropy: %w", err) + } + return p.signer.Sign(base64.RawURLEncoding.EncodeToString(b[:])), nil +} + +// Ensure returns a valid token for the request, reusing the cookie when it is +// present and valid, else issuing a fresh one. +func (p *PreSessionCSRF) Ensure(w http.ResponseWriter, r *http.Request) (string, error) { + if c, err := r.Cookie(PreSessionCookieName); err == nil && c.Value != "" { + if v, ok := p.signer.Unsign(c.Value); ok && v != "" { + return c.Value, nil + } + } + tok, err := p.Token() + if err != nil { + return "", err + } + http.SetCookie(w, &http.Cookie{ + Name: PreSessionCookieName, + Value: tok, + Path: "/", + HttpOnly: true, + Secure: p.secure, + SameSite: http.SameSiteLaxMode, + }) + return tok, nil +} + +// Valid verifies the double-submit: the submitted token must equal the cookie's +// value AND carry a valid signature. The comparison is constant-time. +func (p *PreSessionCSRF) Valid(r *http.Request, submitted string) bool { + if submitted == "" { + return false + } + c, err := r.Cookie(PreSessionCookieName) + if err != nil || c.Value == "" { + return false + } + if !hmac.Equal([]byte(c.Value), []byte(submitted)) { + return false + } + v, ok := p.signer.Unsign(submitted) + return ok && v != "" +} diff --git a/internal/core/httpx/presession_test.go b/internal/core/httpx/presession_test.go new file mode 100644 index 0000000..df6beee --- /dev/null +++ b/internal/core/httpx/presession_test.go @@ -0,0 +1,87 @@ +package httpx_test + +import ( + "net/http/httptest" + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" +) + +func TestSignerRoundTripAndTamper(t *testing.T) { + s, err := httpx.NewSigner([]byte("a-signing-key-0123456789abcdef")) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + if _, err := httpx.NewSigner(nil); err == nil { + t.Error("NewSigner(nil) = nil, want an error (fail closed)") + } + + const value = "hello.world/with/slashes" + tok := s.Sign(value) + got, ok := s.Unsign(tok) + if !ok || got != value { + t.Fatalf("Unsign(Sign(%q)) = %q,%v", value, got, ok) + } + if _, ok := s.Unsign(tok + "x"); ok { + t.Error("Unsign accepted a tampered token") + } + if _, ok := s.Unsign("not-a-token"); ok { + t.Error("Unsign accepted a malformed token") + } + + // A different key must not validate the token. + other, _ := httpx.NewSigner([]byte("another-key-0123456789abcdefgh")) + if _, ok := other.Unsign(tok); ok { + t.Error("a token validated under a different key") + } +} + +func TestPreSessionCSRFEnsureAndValid(t *testing.T) { + p, err := httpx.NewPreSessionCSRF([]byte("a-cookie-secret-0123456789abcdef"), false) + if err != nil { + t.Fatalf("NewPreSessionCSRF: %v", err) + } + + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/someguid", nil) + tok, err := p.Ensure(rec, r) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != httpx.PreSessionCookieName { + t.Fatalf("Ensure cookies = %v, want one %s", cookies, httpx.PreSessionCookieName) + } + if !cookies[0].HttpOnly { + t.Error("the pre-session CSRF cookie is not HttpOnly") + } + + // Double-submit: cookie value must equal the submitted token. + r2 := httptest.NewRequest("POST", "/someguid?do=auth&action=enroll.begin", nil) + r2.AddCookie(cookies[0]) + if !p.Valid(r2, tok) { + t.Error("Valid rejected a matching double-submit token") + } + if p.Valid(r2, tok+"x") { + t.Error("Valid accepted a mismatched token") + } + if p.Valid(httptest.NewRequest("POST", "/", nil), tok) { + t.Error("Valid accepted a token with no cookie") + } + if p.Valid(r2, "") { + t.Error("Valid accepted an empty token") + } + + // Ensure reuses a valid cookie rather than rotating it every call. + rec2 := httptest.NewRecorder() + tok2, err := p.Ensure(rec2, r2) + if err != nil { + t.Fatalf("Ensure(reuse): %v", err) + } + if tok2 != tok { + t.Error("Ensure rotated an already-valid token") + } + if len(rec2.Result().Cookies()) != 0 { + t.Error("Ensure re-issued a cookie for a valid token") + } +} diff --git a/internal/core/httpx/redact.go b/internal/core/httpx/redact.go new file mode 100644 index 0000000..c9af3e3 --- /dev/null +++ b/internal/core/httpx/redact.go @@ -0,0 +1,76 @@ +package httpx + +import ( + "net/url" + "strings" +) + +// Redacted is the placeholder that replaces a credential in logs. +const Redacted = "" + +// sensitiveNames are the segment / parameter names whose VALUE is treated as a +// credential (SPEC-003 §8, A8/A11). The GUID is a public username (D-001) and +// is deliberately NOT in this set — it is never redacted. +var sensitiveNames = map[string]bool{ + "token": true, + "secret": true, + "key": true, + "code": true, + "password": true, + "passwd": true, + "invite": true, + "reset": true, + "session": true, + "csrf": true, +} + +// RedactPath masks credential-bearing path segments so the access log can +// record the raw path shape without leaking a credential (SPEC-003 §8). +// +// A segment that follows a sensitive name (e.g. /setup/token/) +// A `name=value` segment whose name is sensitive (e.g. /x/token=abc) +// +// The GUID is public and is preserved verbatim. Segment count is preserved so +// the logged path stays structurally comparable. +func RedactPath(p string) string { + if p == "" { + return p + } + segs := strings.Split(p, "/") + redactNext := false + for i, seg := range segs { + if redactNext { + segs[i] = Redacted + redactNext = false + continue + } + if name, _, ok := strings.Cut(seg, "="); ok { + if sensitiveNames[strings.ToLower(name)] { + segs[i] = name + "=" + Redacted + continue + } + } + if sensitiveNames[strings.ToLower(seg)] { + redactNext = true + } + } + return strings.Join(segs, "/") +} + +// RedactQuery masks the values of sensitive query parameters. An unparseable +// query is redacted wholesale rather than risking a leak. +func RedactQuery(raw string) string { + if raw == "" { + return "" + } + vals, err := url.ParseQuery(raw) + if err != nil { + return Redacted + } + for name := range vals { + if sensitiveNames[strings.ToLower(name)] { + vals.Set(name, Redacted) + } + } + return vals.Encode() +} diff --git a/internal/core/mail/mail.go b/internal/core/mail/mail.go new file mode 100644 index 0000000..3308b28 --- /dev/null +++ b/internal/core/mail/mail.go @@ -0,0 +1,146 @@ +// Package mail is MyDay's outbound-mail seam (SPEC-007 §4/§5): recovery +// notifications and the enrolment link emailed after an identity rotation. +// +// Email is NEVER an authentication factor (D-005/D-009) — it only *initiates* +// recovery; a human in the chain authorises it. The seam is therefore tiny: an +// interface, an in-memory Fake for tests, and minimal SMTP wiring for +// production. +// +// Spec: docs/specs/spec-007-roles-chain-recovery.md. +package mail + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/smtp" + "strings" + "sync" +) + +// Message is one outbound email. +type Message struct { + To string + Subject string + Body string +} + +// Mailer sends an email. Delivery failures are the caller's to log — the +// contract is deliberately fire-and-forget, like the audit trail. +type Mailer interface { + Send(ctx context.Context, m Message) error +} + +// Fake records every message in memory. It is the test seam. +type Fake struct { + mu sync.Mutex + sent []Message +} + +// NewFake returns an empty recording mailer. +func NewFake() *Fake { return &Fake{} } + +// Send implements Mailer. +func (f *Fake) Send(_ context.Context, m Message) error { + f.mu.Lock() + defer f.mu.Unlock() + f.sent = append(f.sent, m) + return nil +} + +// Sent returns a copy of every recorded message. +func (f *Fake) Sent() []Message { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]Message, len(f.sent)) + copy(out, f.sent) + return out +} + +// To returns every recorded message addressed to addr (case-insensitive). +func (f *Fake) To(addr string) []Message { + f.mu.Lock() + defer f.mu.Unlock() + var out []Message + for _, m := range f.sent { + if strings.EqualFold(strings.TrimSpace(m.To), strings.TrimSpace(addr)) { + out = append(out, m) + } + } + return out +} + +// Nop discards messages. It is the fallback when SMTP is not configured. +type Nop struct{} + +// Send implements Mailer. +func (Nop) Send(context.Context, Message) error { return nil } + +// SMTP sends through a real SMTP server. It is kept deliberately minimal: the +// /setup wizard collects the settings; delivery is best-effort and never +// blocks a request's outcome. +type SMTP struct { + host string + port int + username string + password string + from string + logger *slog.Logger +} + +// NewSMTP builds an SMTP mailer. +func NewSMTP(host string, port int, username, password, from string) *SMTP { + return &SMTP{ + host: strings.TrimSpace(host), + port: port, + username: username, + password: password, + from: strings.TrimSpace(from), + logger: slog.Default(), + } +} + +// Send implements Mailer via net/smtp (STARTTLS/plain auth when a username is +// set). An unconfigured host is a hard error — the caller decides whether that +// matters. +func (s *SMTP) Send(_ context.Context, m Message) error { + if s.host == "" { + return errors.New("mail: SMTP host is not configured") + } + if strings.TrimSpace(m.To) == "" { + return errors.New("mail: recipient is required") + } + addr := fmt.Sprintf("%s:%d", s.host, s.port) + var auth smtp.Auth + if s.username != "" { + auth = smtp.PlainAuth("", s.username, s.password, s.host) + } + if err := smtp.SendMail(addr, auth, s.from, []string{m.To}, encode(s.from, m)); err != nil { + return fmt.Errorf("mail: send to %s: %w", m.To, err) + } + return nil +} + +// encode renders the RFC 5322 message. The body is plain text; every header +// value is stripped of CR/LF first, so a crafted address or subject cannot +// inject extra headers (header-injection guard). +func encode(from string, m Message) []byte { + sanitize := strings.NewReplacer("\r", " ", "\n", " ").Replace + var b strings.Builder + fmt.Fprintf(&b, "From: %s\r\n", sanitize(from)) + fmt.Fprintf(&b, "To: %s\r\n", sanitize(m.To)) + fmt.Fprintf(&b, "Subject: %s\r\n", sanitize(m.Subject)) + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + b.WriteString("\r\n") + b.WriteString(m.Body) + b.WriteString("\r\n") + return []byte(b.String()) +} + +var ( + _ Mailer = (*Fake)(nil) + _ Mailer = Nop{} + _ Mailer = (*SMTP)(nil) +) diff --git a/internal/core/mail/mail_test.go b/internal/core/mail/mail_test.go new file mode 100644 index 0000000..58102e2 --- /dev/null +++ b/internal/core/mail/mail_test.go @@ -0,0 +1,48 @@ +package mail_test + +import ( + "context" + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail" +) + +// TestFakeRecordsAndFilters: the test seam records every message and can filter +// by recipient (case-insensitive). +func TestFakeRecordsAndFilters(t *testing.T) { + f := mail.NewFake() + ctx := context.Background() + + if err := f.Send(ctx, mail.Message{To: "a@example.com", Subject: "one", Body: "hi"}); err != nil { + t.Fatalf("Send: %v", err) + } + if err := f.Send(ctx, mail.Message{To: "B@Example.com", Subject: "two"}); err != nil { + t.Fatalf("Send: %v", err) + } + + if got := len(f.Sent()); got != 2 { + t.Fatalf("Sent len = %d, want 2", got) + } + toB := f.To("b@example.com") + if len(toB) != 1 || toB[0].Subject != "two" { + t.Errorf("To(b@example.com) = %+v, want the second message", toB) + } + if len(f.To("nobody@example.com")) != 0 { + t.Error("To returned a message for an unknown recipient") + } +} + +// TestNopSend: the unconfigured fallback discards silently. +func TestNopSend(t *testing.T) { + if err := (mail.Nop{}).Send(context.Background(), mail.Message{To: "x@example.com"}); err != nil { + t.Errorf("Nop.Send = %v, want nil", err) + } +} + +// TestSMTPRequiresHost: the SMTP mailer fails closed without a host. +func TestSMTPRequiresHost(t *testing.T) { + m := mail.NewSMTP("", 25, "", "", "myday@example.com") + if err := m.Send(context.Background(), mail.Message{To: "x@example.com"}); err == nil { + t.Error("SMTP.Send with no host = nil, want an error") + } +} diff --git a/internal/core/middleware/audit.go b/internal/core/middleware/audit.go new file mode 100644 index 0000000..bb02a38 --- /dev/null +++ b/internal/core/middleware/audit.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "context" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" +) + +// AttachAudit attaches the request's audit metadata to ctx (SPEC-003 §4, ruling +// R-1). The door calls it before dispatching to a module; every service then +// writes its audit rows with the right actor · client IP · correlation. +func AttachAudit(ctx context.Context, actorUserID int64, clientIP, correlationID string) context.Context { + return audit.WithMeta(ctx, audit.Meta{ + ActorUserID: actorUserID, + ClientIP: clientIP, + CorrelationID: correlationID, + }) +} diff --git a/internal/core/middleware/correlation.go b/internal/core/middleware/correlation.go new file mode 100644 index 0000000..00adee1 --- /dev/null +++ b/internal/core/middleware/correlation.go @@ -0,0 +1,57 @@ +// Package middleware holds the door's cross-cutting HTTP concerns — the +// correlation id, the rate limiter, and the audit-metadata attachment +// (SPEC-003 §3/§4). +// +// These are deliberately small and transport-level: the door +// (internal/core/server) orchestrates them in one visible pipeline. +package middleware + +import ( + "context" + "crypto/rand" + "fmt" + "net/http" + "time" +) + +// CorrelationHeader is the response header carrying the correlation id, echoed +// in every log line and audit row (SPEC-003 §4/§8, AC-8). +const CorrelationHeader = "X-Correlation-ID" + +// correlationKey is the unexported context key for the correlation id. +type correlationKey struct{} + +// NewCorrelationID returns a fresh UUID v4 string. It is generated per request: +// an inbound header is never trusted, so a caller cannot forge a trail. +func NewCorrelationID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand failing is a platform fault; fall back to a + // time-derived id rather than emitting an empty correlation. + return fmt.Sprintf("corr-%d", time.Now().UnixNano()) + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// WithCorrelation attaches id to ctx. +func WithCorrelation(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, correlationKey{}, id) +} + +// CorrelationFrom returns the correlation id attached to ctx, or "". +func CorrelationFrom(ctx context.Context) string { + id, _ := ctx.Value(correlationKey{}).(string) + return id +} + +// CorrelationID is the middleware form: it generates a correlation id, puts it +// on the response header and the request context, then calls next. +func CorrelationID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := NewCorrelationID() + w.Header().Set(CorrelationHeader, id) + next.ServeHTTP(w, r.WithContext(WithCorrelation(r.Context(), id))) + }) +} diff --git a/internal/core/middleware/middleware_test.go b/internal/core/middleware/middleware_test.go new file mode 100644 index 0000000..d66b126 --- /dev/null +++ b/internal/core/middleware/middleware_test.go @@ -0,0 +1,123 @@ +package middleware_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware" +) + +func TestNewCorrelationID(t *testing.T) { + a := middleware.NewCorrelationID() + b := middleware.NewCorrelationID() + if a == "" || a == b { + t.Fatalf("correlation ids are empty or identical: %q, %q", a, b) + } + if len(a) != 36 || a[8] != '-' || a[13] != '-' || a[18] != '-' || a[23] != '-' { + t.Errorf("correlation id %q is not UUID-shaped", a) + } +} + +func TestCorrelationIDMiddleware(t *testing.T) { + var seen string + h := middleware.CorrelationID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = middleware.CorrelationFrom(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/home", nil)) + + header := rec.Header().Get(middleware.CorrelationHeader) + if header == "" { + t.Fatal("response is missing the correlation header") + } + if seen != header { + t.Errorf("context correlation = %q, header = %q; want equal", seen, header) + } +} + +func TestCorrelationRoundTrip(t *testing.T) { + if got := middleware.CorrelationFrom(context.Background()); got != "" { + t.Errorf("CorrelationFrom(background) = %q, want empty", got) + } + ctx := middleware.WithCorrelation(context.Background(), "corr-1") + if got := middleware.CorrelationFrom(ctx); got != "corr-1" { + t.Errorf("CorrelationFrom = %q, want corr-1", got) + } +} + +func TestRateLimiterAllow(t *testing.T) { + l := middleware.NewRateLimiter(2, time.Minute) + if !l.Allow("k") || !l.Allow("k") { + t.Fatal("the first two requests should be allowed") + } + if l.Allow("k") { + t.Error("the third request should be denied") + } + if !l.Allow("other") { + t.Error("a different key must have its own bucket") + } +} + +func TestRateLimiterWindowResets(t *testing.T) { + l := middleware.NewRateLimiter(1, time.Minute) + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + l.SetClock(func() time.Time { return now }) + + if !l.Allow("k") || l.Allow("k") { + t.Fatal("limit of 1 not enforced") + } + now = now.Add(2 * time.Minute) + if !l.Allow("k") { + t.Error("the window did not reset") + } +} + +func TestRateLimiterMiddleware429(t *testing.T) { + l := middleware.NewRateLimiter(1, time.Minute) + h := l.Middleware(func(*http.Request) string { return "k" }, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) + + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, httptest.NewRequest("GET", "/access", nil)) + if rec1.Code != http.StatusOK { + t.Fatalf("first request = %d, want 200", rec1.Code) + } + + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, httptest.NewRequest("GET", "/access", nil)) + if rec2.Code != http.StatusTooManyRequests { + t.Fatalf("second request = %d, want 429", rec2.Code) + } + if rec2.Header().Get("Retry-After") == "" { + t.Error("429 is missing a Retry-After header") + } +} + +func TestClassAndAuthSensitive(t *testing.T) { + cases := []struct { + path string + want string + }{ + {"/", ""}, + {"/home", ""}, + {"/api/v1", ""}, + {"/static/x.css", ""}, + {"/access", "access"}, + {"/setup", "setup"}, + {"/AbCdEf0123456789", "guid"}, + {"/AbCdEf0123456789/home", "guid"}, + } + for _, tc := range cases { + if got := middleware.Class(tc.path); got != tc.want { + t.Errorf("Class(%q) = %q, want %q", tc.path, got, tc.want) + } + if got := middleware.AuthSensitive(tc.path); got != (tc.want != "") { + t.Errorf("AuthSensitive(%q) = %v, want %v", tc.path, got, tc.want != "") + } + } +} diff --git a/internal/core/middleware/ratelimit.go b/internal/core/middleware/ratelimit.go new file mode 100644 index 0000000..2b3b256 --- /dev/null +++ b/internal/core/middleware/ratelimit.go @@ -0,0 +1,129 @@ +package middleware + +import ( + "net/http" + "strconv" + "sync" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" +) + +// RateLimiter is a per-key fixed-window limiter (A3, SPEC-003 §4/AC-9). It +// guards the auth-sensitive routes: /{GUID}, /access, /setup. State is +// in-memory and single-process, which matches the single-instance model +// (D-012). +type RateLimiter struct { + mu sync.Mutex + limit int + window time.Duration + now func() time.Time + state map[string]*window +} + +type window struct { + count int + reset time.Time +} + +// DefaultRateLimit / DefaultRateWindow are the code defaults for the +// auth-sensitive routes. +const ( + DefaultRateLimit = 30 + DefaultRateWindow = time.Minute +) + +// NewRateLimiter builds a limiter allowing limit requests per window per key. +// Non-positive values fall back to the defaults. +func NewRateLimiter(limit int, win time.Duration) *RateLimiter { + if limit <= 0 { + limit = DefaultRateLimit + } + if win <= 0 { + win = DefaultRateWindow + } + return &RateLimiter{ + limit: limit, + window: win, + now: time.Now, + state: make(map[string]*window), + } +} + +// Allow records a request for key and reports whether it is within the limit. +func (l *RateLimiter) Allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + + now := l.now() + w, ok := l.state[key] + if !ok || !now.Before(w.reset) { + l.state[key] = &window{count: 1, reset: now.Add(l.window)} + return true + } + w.count++ + return w.count <= l.limit +} + +// SetClock injects a clock (tests). +func (l *RateLimiter) SetClock(fn func() time.Time) { + if fn == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + l.now = fn +} + +// RetryAfter returns the whole seconds until the window resets for key (for the +// Retry-After header). It is best-effort. +func (l *RateLimiter) RetryAfter(key string) int { + l.mu.Lock() + defer l.mu.Unlock() + w, ok := l.state[key] + if !ok { + return 0 + } + d := time.Until(w.reset) + if d < 0 { + return 0 + } + return int(d.Seconds()) + 1 +} + +// Class classifies a path into an auth-sensitive bucket, or "" when the path is +// not rate-limited. /access and /setup are explicit; any non-reserved first +// segment is a GUID lookup. +func Class(path string) string { + p := routing.Parse(path) + if len(p) == 0 || p[0] == "" { + return "" + } + switch p[0] { + case "access", "setup": + return p[0] + } + if routing.IsReservedWorkflow(p[0]) { + return "" + } + return "guid" +} + +// AuthSensitive reports whether path is one of the rate-limited routes (A3). +func AuthSensitive(path string) bool { return Class(path) != "" } + +// Middleware wraps next with a limiter keyed by keyFn. It answers 429 with a +// Retry-After header once the limit is exceeded. +func (l *RateLimiter) Middleware(keyFn func(*http.Request) string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := keyFn(r) + if !l.Allow(key) { + if secs := l.RetryAfter(key); secs > 0 { + w.Header().Set("Retry-After", strconv.Itoa(secs)) + } + http.Error(w, "too many requests", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/core/server/authflow.go b/internal/core/server/authflow.go new file mode 100644 index 0000000..66adee6 --- /dev/null +++ b/internal/core/server/authflow.go @@ -0,0 +1,49 @@ +package server + +import ( + "net/http" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow" +) + +// authModule is the `do` module name carrying the SPEC-006 enroll/login +// actions. The GUID still rides the path — `do`/`action` never carry identity +// (D-032/D-039). +const authModule = "auth" + +// RegisterAuthFlow registers the SPEC-006 core screens (enroll · login) and the +// `auth` action module into reg. It is the wiring the door needs to make +// `POST /{GUID}?do=auth&action=…` resolve and dispatch. +func RegisterAuthFlow(reg *MapRegistry, a *flow.Actions) { + reg.RegisterCore("enroll", adaptFlow(a.EnrollScreen)) + reg.RegisterCore("login", adaptFlow(a.LoginScreen)) + + reg.RegisterModule(NewModule(authModule). + // SPEC-006 §8 (D-041): the session-gated passkey-nudge screen. It is a + // ROUTE on the auth module (GET /{GUID}/auth/passkey) — the redirect + // target of a nudged TOTP login. + AddRoute("passkey", "passkey.prompt", adaptFlow(a.PasskeyPrompt)). + AddAction("enroll.begin", "enroll.begin", adaptFlow(a.EnrollBegin)). + AddAction("enroll.finish", "enroll.finish", adaptFlow(a.EnrollFinish)). + AddAction("totp.begin", "totp.begin", adaptFlow(a.TOTPBegin)). + AddAction("totp.verify", "totp.verify", adaptFlow(a.TOTPVerify)). + AddAction("login.begin", "login.begin", adaptFlow(a.LoginBegin)). + AddAction("login.finish", "login.finish", adaptFlow(a.LoginFinish)). + AddAction("logout", "logout", adaptFlow(a.Logout)). + // The nudge actions carry a session, so the door session-gates them + // (serveAuthAction forwards the `passkey.` prefix to serveGUID). + AddAction("passkey.add", "passkey.add", adaptFlow(a.PasskeyAdd)). + AddAction("passkey.later", "passkey.later", adaptFlow(a.PasskeyLater)). + AddAction("passkey.never", "passkey.never", adaptFlow(a.PasskeyNever))) +} + +// flowFunc is a flow handler: the GUID comes from the RequestContext (the door +// resolves it from the path; the flow never parses a raw path). +type flowFunc func(w http.ResponseWriter, r *http.Request, guid string) error + +// adaptFlow adapts a flow handler to the door's Handler signature. +func adaptFlow(f flowFunc) Handler { + return func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error { + return f(w, r, rc.Workflow) + } +} diff --git a/internal/core/server/enroll_login_test.go b/internal/core/server/enroll_login_test.go new file mode 100644 index 0000000..56ced62 --- /dev/null +++ b/internal/core/server/enroll_login_test.go @@ -0,0 +1,488 @@ +package server_test + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + pqotp "github.com/pquerna/otp/totp" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +// ── the WebAuthn fake (the ceremony is browser-bound) ──────────────────────── + +type fakeWebAuthn struct { + beginReg, finishReg int + beginLogin, finishLogin int + beginErr, finishErr error +} + +func (f *fakeWebAuthn) BeginRegistration(context.Context, webauthn.User, []webauthn.Credential) (*webauthn.Begin, error) { + f.beginReg++ + if f.beginErr != nil { + return nil, f.beginErr + } + return fakeBegin("reg-challenge"), nil +} + +func (f *fakeWebAuthn) FinishRegistration(context.Context, webauthn.User, *webauthn.Begin, *http.Request) (webauthn.Credential, error) { + f.finishReg++ + if f.finishErr != nil { + return webauthn.Credential{}, f.finishErr + } + return webauthn.Credential{ + ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"), + SignCount: 1, Transports: []string{"internal"}, AAGUID: []byte{1, 2, 3, 4}, + }, nil +} + +func (f *fakeWebAuthn) BeginLogin(context.Context, webauthn.User, []webauthn.Credential) (*webauthn.Begin, error) { + f.beginLogin++ + if f.beginErr != nil { + return nil, f.beginErr + } + return fakeBegin("login-challenge"), nil +} + +func (f *fakeWebAuthn) FinishLogin(context.Context, webauthn.User, *webauthn.Begin, *http.Request, []webauthn.Credential) (webauthn.Credential, error) { + f.finishLogin++ + if f.finishErr != nil { + return webauthn.Credential{}, f.finishErr + } + return webauthn.Credential{ + ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"), + SignCount: 2, Transports: []string{"internal"}, AAGUID: []byte{1, 2, 3, 4}, + }, nil +} + +func fakeBegin(challenge string) *webauthn.Begin { + opts, _ := json.Marshal(map[string]any{ + "publicKey": map[string]any{"challenge": challenge}, + }) + sd, _ := json.Marshal(map[string]any{ + "challenge": challenge, + "expires": time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339), + }) + return &webauthn.Begin{Options: opts, SessionData: sd} +} + +// ── harness helpers ────────────────────────────────────────────────────────── + +// pendingUser creates a team user under the admin and mints a pending identity. +func (h *harness) pendingUser(t *testing.T, email string) (userID, identityID int64, guid string) { + t.Helper() + userID, err := h.users.Create(h.ctx, h.adminID, email, "Pending", users.RoleTeam) + if err != nil { + t.Fatalf("create user: %v", err) + } + identityID, guid, err = h.ids.Mint(h.ctx, userID) + if err != nil { + t.Fatalf("Mint: %v", err) + } + return userID, identityID, guid +} + +// action issues an anonymous flow action with a fresh pre-session CSRF token. +func (h *harness) action(action, guid string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { + h.t.Helper() + cookie, token := h.preCSRF() + if form == nil { + form = url.Values{} + } + form.Set("csrf", token) + + req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action="+url.QueryEscape(action), + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + return rec +} + +// sessionAction issues a session-gated action (logout) with the session-bound +// CSRF token. +func (h *harness) sessionAction(action, guid, sessionID string, extra ...*http.Cookie) *httptest.ResponseRecorder { + h.t.Helper() + form := url.Values{"csrf": {h.csrf.Token(sessionID)}} + req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action="+url.QueryEscape(action), + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: httpx.SessionCookieName, Value: sessionID}) + for _, c := range extra { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + return rec +} + +// ceremonyCookie extracts the signed WebAuthn ceremony cookie from a response. +func ceremonyCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == flow.CeremonyCookieName && c.Value != "" { + return c + } + } + t.Fatalf("response carried no %s cookie", flow.CeremonyCookieName) + return nil +} + +// sessionCookie extracts the session cookie value from a response. +func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == httpx.SessionCookieName && c.Value != "" { + return c.Value + } + } + t.Fatalf("response carried no session cookie (status %d)", rec.Code) + return "" +} + +// enrollFully runs the whole two-factor enrollment and returns the session id. +func (h *harness) enrollFully(t *testing.T, identityID int64, guid string) string { + t.Helper() + + // Factor 1 — passkey. + begin := h.action("enroll.begin", guid, nil) + if begin.Code != http.StatusOK { + t.Fatalf("enroll.begin = %d, want 200", begin.Code) + } + finish := h.action("enroll.finish", guid, nil, ceremonyCookie(t, begin)) + if finish.Code != http.StatusOK { + t.Fatalf("enroll.finish = %d, want 200", finish.Code) + } + + // Factor 2 — TOTP. + if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusOK { + t.Fatalf("totp.begin = %d, want 200", rec.Code) + } + secret, err := h.ids.TOTPSecret(h.ctx, identityID) + if err != nil { + t.Fatalf("TOTPSecret: %v", err) + } + code, err := pqotp.GenerateCode(string(secret), time.Now()) + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + done := h.action("totp.verify", guid, url.Values{"code": {code}}) + if done.Code != http.StatusSeeOther { + t.Fatalf("totp.verify = %d, want 303 (enrollment complete)", done.Code) + } + return sessionCookie(t, done) +} + +// ── AC-1 ───────────────────────────────────────────────────────────────────── + +func TestSpec006AC1_PendingRoutesToEnrollNotLogin(t *testing.T) { + h := newHarness(t) + _, _, guid := h.pendingUser(t, "ac1@example.com") + + rec := request(h.srv, "GET", "/"+guid) + if rec.Code != http.StatusOK { + t.Fatalf("GET /{pending-guid} = %d, want 200 (enroll)", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Enrollment") { + t.Error("a pending GUID did not render the enrollment screen (AC-1)") + } + if strings.Contains(body, "Sign in") { + t.Error("a pending GUID rendered the login screen (AC-1)") + } + // The anonymous screen issues the pre-session CSRF cookie (A15). + if !hasCookie(rec, httpx.PreSessionCookieName) { + t.Error("the enrollment screen issued no pre-session CSRF cookie (A15)") + } +} + +// ── AC-2 ───────────────────────────────────────────────────────────────────── + +func TestSpec006AC2_EnrollmentRequiresBothFactors(t *testing.T) { + h := newHarness(t) + _, id, guid := h.pendingUser(t, "ac2@example.com") + + // TOTP first, no passkey yet -> refused; identity stays pending. + if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusOK { + t.Fatalf("totp.begin = %d, want 200", rec.Code) + } + secret, err := h.ids.TOTPSecret(h.ctx, id) + if err != nil { + t.Fatalf("TOTPSecret: %v", err) + } + code, err := pqotp.GenerateCode(string(secret), time.Now()) + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + rec := h.action("totp.verify", guid, url.Values{"code": {code}}) + if rec.Code != http.StatusBadRequest { + t.Errorf("totp.verify without a passkey = %d, want 400 (AC-2)", rec.Code) + } + if hasCookie(rec, httpx.SessionCookieName) { + t.Error("a session was minted without both factors (AC-2)") + } + if idn, err := h.ids.ByGUID(h.ctx, guid); err != nil || idn.Status != auth.StatusPendingEnrollment { + t.Errorf("identity after one factor = %+v, want pending_enrollment (AC-2)", idn) + } + + // Passkey, no verified TOTP -> still pending, still no session. + begin := h.action("enroll.begin", guid, nil) + if begin.Code != http.StatusOK { + t.Fatalf("enroll.begin = %d, want 200", begin.Code) + } + finish := h.action("enroll.finish", guid, nil, ceremonyCookie(t, begin)) + if finish.Code != http.StatusOK { + t.Fatalf("enroll.finish = %d, want 200", finish.Code) + } + if hasCookie(finish, httpx.SessionCookieName) { + t.Error("enroll.finish minted a session before TOTP (AC-2)") + } + if idn, err := h.ids.ByGUID(h.ctx, guid); err != nil || idn.Status != auth.StatusPendingEnrollment { + t.Errorf("identity after passkey only = %+v, want pending_enrollment (AC-2)", idn) + } + + // Now both -> active + session. + code, err = pqotp.GenerateCode(string(secret), time.Now()) + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + rec = h.action("totp.verify", guid, url.Values{"code": {code}}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("totp.verify with both factors = %d, want 303 (AC-2)", rec.Code) + } + if !hasCookie(rec, httpx.SessionCookieName) { + t.Error("no session minted after both factors (AC-2)") + } + idn, err := h.ids.ByGUID(h.ctx, guid) + if err != nil { + t.Fatalf("ByGUID: %v", err) + } + if idn.Status != auth.StatusActive { + t.Errorf("identity after both factors = %s, want active (AC-2)", idn.Status) + } +} + +// ── AC-3 ───────────────────────────────────────────────────────────────────── + +func TestSpec006AC3_ActiveRoutesToLoginThenApp(t *testing.T) { + h := newHarness(t) // the admin is active + + rec := request(h.srv, "GET", "/"+h.guid) + if rec.Code != http.StatusOK { + t.Errorf("GET /{active-guid} without a session = %d, want 200 (login screen, F-1)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Sign in") { + t.Error("an active GUID without a session did not render the login screen (AC-3)") + } + + rec = request(h.srv, "GET", "/"+h.guid, h.cookie()) + if rec.Code != http.StatusOK { + t.Fatalf("GET /{active-guid} with a session = %d, want 200 (the app)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "MyDay") { + t.Error("a valid session did not reach the app (AC-3)") + } +} + +// ── AC-4 ───────────────────────────────────────────────────────────────────── + +func TestSpec006AC4_WindowExpiryBlocksEnrollment(t *testing.T) { + h := newHarness(t) + _, id, guid := h.pendingUser(t, "ac4@example.com") + + // Age the identity beyond the bounded window (A1). + past := store.FormatTime(time.Now().Add(-100 * time.Hour)) + if _, err := h.db.Exec(`UPDATE identities SET created_at = ? WHERE identity_id = ?`, past, id); err != nil { + t.Fatalf("age identity: %v", err) + } + + if rec := request(h.srv, "GET", "/"+guid); rec.Code != http.StatusNotFound { + t.Errorf("GET /{expired-guid} = %d, want 404 (A1)", rec.Code) + } + if rec := h.action("enroll.begin", guid, nil); rec.Code != http.StatusNotFound { + t.Errorf("enroll.begin past the window = %d, want 404 (A1)", rec.Code) + } + if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusNotFound { + t.Errorf("totp.begin past the window = %d, want 404 (A1)", rec.Code) + } +} + +// ── AC-5 ───────────────────────────────────────────────────────────────────── + +func TestSpec006AC5_LoginRotatesAndLogoutRevokes(t *testing.T) { + h := newHarness(t) + _, id, guid := h.pendingUser(t, "ac5@example.com") + s1 := h.enrollFully(t, id, guid) + + if _, err := h.sessions.Validate(h.ctx, s1); err != nil { + t.Fatalf("the enrollment session is invalid: %v", err) + } + + // A passkey login while S1 is live rotates it (fixation defence). + begin := h.action("login.begin", guid, nil, &http.Cookie{Name: httpx.SessionCookieName, Value: s1}) + if begin.Code != http.StatusOK { + t.Fatalf("login.begin = %d, want 200", begin.Code) + } + finish := h.action("login.finish", guid, nil, + &http.Cookie{Name: httpx.SessionCookieName, Value: s1}, ceremonyCookie(t, begin)) + if finish.Code != http.StatusSeeOther { + t.Fatalf("login.finish = %d, want 303", finish.Code) + } + s2 := sessionCookie(t, finish) + if s2 == s1 { + t.Error("login did not rotate the session id (AC-5)") + } + if _, err := h.sessions.Validate(h.ctx, s1); err == nil { + t.Error("the pre-login session survived rotation (AC-5)") + } + if _, err := h.sessions.Validate(h.ctx, s2); err != nil { + t.Errorf("the rotated session is invalid: %v", err) + } + + // Logout revokes S2. + lo := h.sessionAction("logout", guid, s2) + if lo.Code != http.StatusSeeOther { + t.Fatalf("logout = %d, want 303", lo.Code) + } + if _, err := h.sessions.Validate(h.ctx, s2); err == nil { + t.Error("logout did not revoke the session (AC-5)") + } +} + +// ── AC-6 ───────────────────────────────────────────────────────────────────── + +func TestSpec006AC6_TOTPSecretEncryptedAndNeverLogged(t *testing.T) { + var logs bytes.Buffer + h := newHarness(t, func(d *server.Deps) { + d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + }) + _, id, guid := h.pendingUser(t, "ac6@example.com") + + rec := h.action("totp.begin", guid, nil) + if rec.Code != http.StatusOK { + t.Fatalf("totp.begin = %d, want 200", rec.Code) + } + + secret, err := h.ids.TOTPSecret(h.ctx, id) + if err != nil { + t.Fatalf("TOTPSecret: %v", err) + } + if len(secret) == 0 { + t.Fatal("the issued secret is empty") + } + + // 1) The stored blob is ciphertext, not the secret (A9). + var raw []byte + if err := h.db.QueryRow(`SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, id).Scan(&raw); err != nil { + t.Fatalf("read secret_encrypted: %v", err) + } + if bytes.Contains(raw, secret) { + t.Error("AC-6: secret_encrypted holds the plaintext secret (A9)") + } + + // 2) The otpauth URL is shown to the user (the one display) — but the + // secret never reached the log (A9). + if !strings.Contains(rec.Body.String(), "otpauth://") { + t.Error("totp.begin did not return an otpauth URL") + } + if strings.Contains(logs.String(), string(secret)) { + t.Error("AC-6: the TOTP secret reached the log (A9)") + } +} + +// ── AC-7 ───────────────────────────────────────────────────────────────────── + +func TestSpec006AC7_RateLimitAndGenericErrors(t *testing.T) { + // Rate limit (A3): the enroll/login routes return 429 past the limit. + h := newHarness(t, func(d *server.Deps) { + d.RateLimit = 1 + d.RateWindow = time.Minute + }) + _, _, guid := h.pendingUser(t, "ac7@example.com") + if rec := request(h.srv, "GET", "/"+guid); rec.Code != http.StatusOK { + t.Fatalf("first request = %d, want 200", rec.Code) + } + rec := request(h.srv, "GET", "/"+guid) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("second request = %d, want 429 (A3/AC-7)", rec.Code) + } + if rec.Header().Get("Retry-After") == "" { + t.Error("429 is missing a Retry-After header") + } + + // Generic error text: failures never reveal which factor or why. + h2 := newHarness(t) + _, id2, guid2 := h2.pendingUser(t, "ac7b@example.com") + h2.enrollFully(t, id2, guid2) + + rec = h2.action("totp.verify", guid2, url.Values{"code": {"000000"}}) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("bad TOTP code = %d, want 401", rec.Code) + } + if got := strings.TrimSpace(rec.Body.String()); got != "unauthorized" { + t.Errorf("failed login body = %q, want a generic %q", got, "unauthorized") + } + + // A missing/forged pre-session token is rejected generically. + req := httptest.NewRequest("POST", "/"+guid2+"?do=auth&action=enroll.begin", nil) + req.AddCookie(&http.Cookie{Name: httpx.PreSessionCookieName, Value: "forged"}) + rec2 := httptest.NewRecorder() + h2.srv.ServeHTTP(rec2, req) + if rec2.Code != http.StatusForbidden { + t.Errorf("forged pre-session token = %d, want 403 (A15)", rec2.Code) + } + if got := strings.TrimSpace(rec2.Body.String()); got != "forbidden" { + t.Errorf("CSRF failure body = %q, want a generic %q", got, "forbidden") + } +} + +// TestF2_NoStoreOnEnrollAndTOTPResponses: the enrollment screen and the TOTP +// issue response (which embeds the otpauth URL — the secret) are never cached. +func TestF2_NoStoreOnEnrollAndTOTPResponses(t *testing.T) { + h := newHarness(t) + _, _, guid := h.pendingUser(t, "f2@example.com") + + rec := request(h.srv, "GET", "/"+guid) + if rec.Code != http.StatusOK { + t.Fatalf("enrollment screen = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("enrollment screen Cache-Control = %q, want no-store (F-2)", got) + } + + rec = h.action("totp.begin", guid, nil) + if rec.Code != http.StatusOK { + t.Fatalf("totp.begin = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("totp.begin Cache-Control = %q, want no-store (F-2)", got) + } +} + +// hasCookie reports whether the response set a non-empty cookie of that name. +func hasCookie(rec *httptest.ResponseRecorder, name string) bool { + for _, c := range rec.Result().Cookies() { + if c.Name == name && c.Value != "" { + return true + } + } + return false +} diff --git a/internal/core/server/handlers.go b/internal/core/server/handlers.go new file mode 100644 index 0000000..1914e87 --- /dev/null +++ b/internal/core/server/handlers.go @@ -0,0 +1,66 @@ +package server + +import ( + "net/http" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" +) + +// The core handlers below are the door's minimal landing surfaces. SPEC-003 §4 +// says the door renders no dynamic HTML — it resolves, gates, and hands off; +// these are the hand-off targets for the routes SPEC-003 must classify, and the +// real screens arrive with SPEC-005 (setup), SPEC-006 (enroll/login) and +// SPEC-007 (access). SPEC-009 §4 renders them inside the shared shell. + +// Home is the neutral landing page. It is PUBLIC: it takes no session and +// touches no database (SPEC-003 §9 AC-2). It is registered both as the core +// `home` workflow (level 1, /home) and as the `home` module's dashboard (level +// 2, /{GUID}). +func Home(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error { + return web.Render(w, http.StatusOK, web.Page{ + Title: "MyDay", + Guest: true, + Content: web.Markup(`

MyDay

+

Welcome. Sign in with your personal link.

`), + }) +} + +// LoginPlaceholder stands in for the login screen (SPEC-006). It answers 200: +// a rendered page is a successful response — only a failed credential attempt +// is 401/400 (F-1). +func LoginPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`

Sign in

` + + `

This link requires a valid session.

`)) + return err +} + +// EnrollPlaceholder stands in for the enrollment screen (SPEC-006). The GUID is +// found but not yet enrolled, so no session is required to see it. +func EnrollPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`

Enrollment

` + + `

Passkey and TOTP enrollment.

`)) + return err +} + +// SetupPlaceholder stands in for the /setup wizard (SPEC-005). The door only +// reaches it in setup mode. +func SetupPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`

Setup

` + + `

Bootstrap wizard.

`)) + return err +} + +// AccessPlaceholder stands in for the recovery entry (SPEC-007). +func AccessPlaceholder(w http.ResponseWriter, _ *http.Request, _ *RequestContext) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`

Account access

` + + `

Recovery.

`)) + return err +} diff --git a/internal/core/server/recoveryflow.go b/internal/core/server/recoveryflow.go new file mode 100644 index 0000000..672ffa6 --- /dev/null +++ b/internal/core/server/recoveryflow.go @@ -0,0 +1,52 @@ +package server + +import ( + "net/http" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery" +) + +// recoveryModule is the `do` module carrying the SPEC-007 approve/deny actions. +// It rides a GUID path, so the door session-gates it (unlike the anonymous +// `auth` recover action). +const recoveryModule = "recovery" + +// RegisterRecoveryFlow registers SPEC-007's surface: +// +// - the core `access` workflow (GET /access — the anonymous recovery entry), +// - the `auth` module's `recover` action (POST /access?do=auth&action=recover, +// anonymous, A15), and +// - the session-gated `recovery` approve/deny actions +// (POST /{GUID}?do=recovery&action=approve|deny). +func RegisterRecoveryFlow(reg *MapRegistry, a *recovery.Actions) { + reg.RegisterCore("access", adaptRecovery(a.Page)) + + // The recovery request rides the `auth` action module, exactly as SPEC-007 + // §4 spells it: POST /access?do=auth&action=recover. The module may already + // exist (SPEC-006's enroll/login); extend it rather than replace it. + auth := reg.module(authModule) + if auth == nil { + auth = NewModule(authModule) + reg.RegisterModule(auth) + } + auth.AddAction("recover", "recover.request", adaptRecovery(a.Request)) + + reg.RegisterModule(NewModule(recoveryModule). + AddAction("approve", "approve", adaptRecoveryActor(a.Approve)). + AddAction("deny", "deny", adaptRecoveryActor(a.Deny))) +} + +// adaptRecovery adapts an anonymous recovery handler to the door's Handler. +func adaptRecovery(f func(http.ResponseWriter, *http.Request) error) Handler { + return func(w http.ResponseWriter, r *http.Request, _ *RequestContext) error { + return f(w, r) + } +} + +// adaptRecoveryActor adapts a session-gated decision handler: the door has +// authenticated the actor and put its id on the RequestContext. +func adaptRecoveryActor(f func(http.ResponseWriter, *http.Request, int64) error) Handler { + return func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error { + return f(w, r, rc.UserID) + } +} diff --git a/internal/core/server/registry.go b/internal/core/server/registry.go new file mode 100644 index 0000000..c9cdedb --- /dev/null +++ b/internal/core/server/registry.go @@ -0,0 +1,162 @@ +package server + +import ( + "net/http" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" +) + +// Handler is a dispatched module function. It receives the door's resolved +// RequestContext — identification and routing, never a raw path (rule of the +// door). A non-nil error is logged and answered as 500. +type Handler func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error + +// Registry is the door's dispatch surface: the pure routing.Registry (so +// routing.Dispatch can resolve a Resolution) plus the function lookup that +// turns a resolved (module, function) into a Handler. +// +// SPEC-008 owns the full, discovered registry; this is the minimal in-memory +// implementation that makes the door exercisable. +type Registry interface { + // routing.Registry is the whitelist the pure model consults. + routing.Registry + + // Handler returns the function registered for (module, function). + Handler(module, function string) (Handler, bool) + + // CoreHandler returns the handler for a core-owned workflow (home, access, + // setup, login, enroll, …) — the reserved level-1 routes. + CoreHandler(workflow string) (Handler, bool) +} + +// MapModule is one in-memory module: its routing.Module whitelist plus its +// function handlers. +type MapModule struct { + name string + deflt string + routes map[string]string + actions map[string]string + handlers map[string]Handler +} + +// NewModule starts a module definition. +func NewModule(name string) *MapModule { + return &MapModule{ + name: name, + routes: make(map[string]string), + actions: make(map[string]string), + handlers: make(map[string]Handler), + } +} + +// Name implements routing.Module. +func (m *MapModule) Name() string { return m.name } + +// SetDefault declares the function (and handler) for an absent/trailing PARAM3. +func (m *MapModule) SetDefault(fn string, h Handler) *MapModule { + m.deflt = fn + if h != nil { + m.handlers[fn] = h + } + return m +} + +// AddRoute registers a whitelisted path-mode function. +func (m *MapModule) AddRoute(name, fn string, h Handler) *MapModule { + m.routes[name] = fn + if h != nil { + m.handlers[fn] = h + } + return m +} + +// AddAction registers a whitelisted action-mode function. +func (m *MapModule) AddAction(name, fn string, h Handler) *MapModule { + m.actions[name] = fn + if h != nil { + m.handlers[fn] = h + } + return m +} + +// Route implements routing.Module. +func (m *MapModule) Route(function string, tail routing.Params) (string, routing.Params, bool) { + if function == "" { + if m.deflt == "" { + return "", nil, false + } + return m.deflt, tail, true + } + if fn, ok := m.routes[function]; ok { + return fn, tail, true + } + return "", nil, false +} + +// Action implements routing.Module. +func (m *MapModule) Action(action string) (string, bool) { + fn, ok := m.actions[action] + return fn, ok +} + +// MapRegistry is the minimal in-memory Registry. +type MapRegistry struct { + modules map[string]*MapModule + core map[string]Handler +} + +// NewMapRegistry returns an empty registry. +func NewMapRegistry() *MapRegistry { + return &MapRegistry{ + modules: make(map[string]*MapModule), + core: make(map[string]Handler), + } +} + +// RegisterModule adds a module under PARAM2. +func (r *MapRegistry) RegisterModule(m *MapModule) *MapRegistry { + r.modules[m.name] = m + return r +} + +// RegisterCore adds a core-owned workflow handler (home, access, setup, login, +// enroll). +func (r *MapRegistry) RegisterCore(workflow string, h Handler) *MapRegistry { + r.core[workflow] = h + return r +} + +// module returns the registered MapModule (nil when absent). It is the +// package-internal handle a registration helper uses to EXTEND an existing +// module — e.g. the SPEC-006 `auth` module gaining SPEC-007's `recover` action. +func (r *MapRegistry) module(name string) *MapModule { return r.modules[name] } + +// Module implements routing.Registry. An unknown module is "not found" → 404. +func (r *MapRegistry) Module(name string) (routing.Module, bool) { + m, ok := r.modules[name] + if !ok { + return nil, false + } + return m, true +} + +// Handler returns the function handler for (module, function). +func (r *MapRegistry) Handler(module, function string) (Handler, bool) { + m, ok := r.modules[module] + if !ok { + return nil, false + } + h, ok := m.handlers[function] + return h, ok +} + +// CoreHandler returns the core handler for a reserved workflow. +func (r *MapRegistry) CoreHandler(workflow string) (Handler, bool) { + h, ok := r.core[workflow] + return h, ok +} + +var ( + _ Registry = (*MapRegistry)(nil) + _ routing.Module = (*MapModule)(nil) +) diff --git a/internal/core/server/requestcontext.go b/internal/core/server/requestcontext.go new file mode 100644 index 0000000..84920c5 --- /dev/null +++ b/internal/core/server/requestcontext.go @@ -0,0 +1,70 @@ +package server + +import ( + "strconv" + "strings" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +// RequestContext is the in-process hand-over from the door to a module function +// (SPEC-003 §5, D-003). It carries IDENTIFICATION, never a credential — the +// Principal and the session cookie are separate concerns (D-001). +// +// Field names match the GMS `X-*-*` headers so a future process split is +// mechanical (D-003). +type RequestContext struct { + // Workflow is the PARAM1 classification: a reserved workflow ("home", + // "access", …) or the resolved GUID. + Workflow string + // UserID is the resolved user (0 when anonymous). + UserID int64 + // Principal is "user:" — IDENTIFICATION only, never a credential. + Principal string + // Function is the resolved module function / action. + Function string + // Page is the joined path remainder handed to the function. + Page string + // Correlation is the request's correlation id. + Correlation string + // ClientIP is the resolved client IP. + ClientIP string + // Location is IP-derived and AUDIT ONLY (D-031) — no tracking. + Location string +} + +// newRequestContext builds the hand-over from a resolution. +func newRequestContext(res routing.Resolution, userID int64, correlation, clientIP string) *RequestContext { + principal := "anonymous" + if userID != 0 { + principal = "user:" + strconv.FormatInt(userID, 10) + } + return &RequestContext{ + Workflow: res.Workflow, + UserID: userID, + Principal: principal, + Function: res.Function, + Page: strings.Join(res.Params, "/"), + Correlation: correlation, + ClientIP: clientIP, + } +} + +// RoleGate decides whether an authenticated user's role may call a given +// (module, function) (SPEC-003 §4 step 5d). +// +// SPEC-003 does not fix the policy — that is SPEC-007/SPEC-008. The door +// enforces the gate; the default implementation admits any authenticated user +// (the tier model ripples down, D-034). +type RoleGate interface { + Allowed(role users.Role, module, function string) bool +} + +// AllowAuthenticated is the default RoleGate: every authenticated user may call +// every registered function. The one-admin and role-above rules are enforced by +// the users service; per-module authorisation arrives with SPEC-007/008. +type AllowAuthenticated struct{} + +// Allowed implements RoleGate. +func (AllowAuthenticated) Allowed(users.Role, string, string) bool { return true } diff --git a/internal/core/server/roles.go b/internal/core/server/roles.go new file mode 100644 index 0000000..b7db4c6 --- /dev/null +++ b/internal/core/server/roles.go @@ -0,0 +1,50 @@ +package server + +import "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" + +// Tiers is the D-034 RoleGate: simple tiers, ONE role per user, and the +// assigned role RIPPLES DOWN (admin > director > manager > team) — a director +// may do everything a manager and a team member may. There are no tick-boxes +// and no ACL. +// +// A function carries a MINIMUM required role. With no explicit requirement the +// floor is team, so every authenticated user is admitted — the door still +// enforces session + identity before this gate runs (SPEC-003 §4 step 5d). +// +// The role-ABOVE rules that need the *other* party (creation authority, +// recovery approval) do not live here: they cannot be decided from +// (role, module, function) alone, so the users service enforces them +// (SPEC-007 AC-1/AC-2). +type Tiers struct { + required map[string]users.Role + floor users.Role +} + +// NewTiers returns the tiered gate with the default floor (team). +func NewTiers() *Tiers { + return &Tiers{required: make(map[string]users.Role), floor: users.RoleTeam} +} + +// Require records the minimum role a (module, function) needs and returns the +// receiver for chaining. An unknown (module, function) keeps the floor. +func (t *Tiers) Require(module, function string, min users.Role) *Tiers { + if t.required == nil { + t.required = make(map[string]users.Role) + } + t.required[gateKey(module, function)] = min + return t +} + +// Allowed implements RoleGate. The ripple-down rule is simply rank >= minimum; +// an invalid role (rank 0) never passes — fail closed. +func (t *Tiers) Allowed(role users.Role, module, function string) bool { + min, ok := t.required[gateKey(module, function)] + if !ok { + min = t.floor + } + return role.Rank() >= min.Rank() +} + +func gateKey(module, function string) string { return module + "/" + function } + +var _ RoleGate = (*Tiers)(nil) diff --git a/internal/core/server/roles_test.go b/internal/core/server/roles_test.go new file mode 100644 index 0000000..c27ca28 --- /dev/null +++ b/internal/core/server/roles_test.go @@ -0,0 +1,49 @@ +package server + +import ( + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +// TestTiersRippleDown proves the D-034 role gate: simple tiers, one role per +// user, and a role implies every role below it. +func TestTiersRippleDown(t *testing.T) { + g := NewTiers() + + // No explicit requirement -> the floor (team): every authenticated role is + // admitted. + for _, r := range []users.Role{users.RoleTeam, users.RoleManager, users.RoleDirector, users.RoleAdmin} { + if !g.Allowed(r, "home", "dashboard") { + t.Errorf("%s was denied an unlisted function (floor = team)", r) + } + } + // An invalid/empty role fails closed. + if g.Allowed(users.Role("boss"), "home", "dashboard") { + t.Error("an invalid role was admitted by the tier gate") + } + if g.Allowed(users.Role(""), "home", "dashboard") { + t.Error("an empty role was admitted by the tier gate") + } + + // A function requiring manager: team is denied, manager and above pass + // (ripple-down). + g.Require("users", "create", users.RoleManager) + if g.Allowed(users.RoleTeam, "users", "create") { + t.Error("team was admitted to a manager function") + } + for _, r := range []users.Role{users.RoleManager, users.RoleDirector, users.RoleAdmin} { + if !g.Allowed(r, "users", "create") { + t.Errorf("%s was denied a manager function (ripple-down)", r) + } + } + + // A director function: only director and admin pass. + g.Require("users", "promote", users.RoleDirector) + if g.Allowed(users.RoleManager, "users", "promote") { + t.Error("manager was admitted to a director function") + } + if !g.Allowed(users.RoleAdmin, "users", "promote") { + t.Error("admin was denied a director function") + } +} diff --git a/internal/core/server/server.go b/internal/core/server/server.go new file mode 100644 index 0000000..d343b49 --- /dev/null +++ b/internal/core/server/server.go @@ -0,0 +1,591 @@ +// Package server is MyDay's ONE door (SPEC-003 §1/§4): it turns an inbound +// request into a dispatched module function, applying identity · session · role +// and every cross-cutting concern. The door RESOLVES and GATES; a module never +// sees a request it should not (D-002, D-039). +// +// The pipeline (SPEC-003 §4): +// +// 1. correlation id -> context + response header +// 2. client IP -> from server.trusted_proxies (SET XFF, A6) +// 3. security headers +// 4. rate limit -> /{GUID}, /access, /setup (A3) +// 5. routing.Dispatch -> the pure model (SPEC-002) +// 6. the Kind switch -> Redirect · NotFound · Setup · Enroll · Route/Action +// Route/Action under a GUID -> the identity/session/role gate: +// session valid? (non-empty, not expired/revoked — A2) +// session.user_id == the GUID's user? +// role permits the function? +// attach audit.Meta -> dispatch +// +// Spec: docs/specs/spec-003-core-door.md. +package server + +import ( + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "strconv" + "strings" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +// ErrNonLoopbackSetup is returned when a non-loopback listen address is +// requested while in setup mode (D-025) — the process must refuse to start. +var ErrNonLoopbackSetup = errors.New("server: refusing to bind a non-loopback address in setup mode") + +// Deps assembles the door's dependencies. +type Deps struct { + // Config is the loaded configuration. + Config *config.Config + // Resolver answers the pure model's GUID question (SPEC-002 §3.1). + Resolver routing.Resolver + // Registry is the module + core-handler dispatch surface. + Registry Registry + // Sessions validates the session cookie. + Sessions auth.Sessions + // Identities resolves a GUID to a user (the door's own lookup, distinct + // from the pure Resolver surface). + Identities auth.Identities + // Users loads the authenticated user's role for the role gate. + Users users.Users + // Audit receives the door's and the services' audit rows. + Audit audit.Audit + // CSRF verifies action requests (A8). + CSRF *httpx.CSRF + // PreSession verifies the anonymous enroll/login double-submit token + // (A15). When nil the `auth` action module is unavailable (404). + PreSession *httpx.PreSessionCSRF + // TrustedProxies overrides the set parsed from Config.Server.TrustedProxies. + TrustedProxies httpx.TrustedProxies + // SetupMode reports whether the instance is in setup mode (D-025). When nil + // it defaults to Config.Setup; the entrypoint widens it to "or no user + // exists" (SPEC-005 §3). + SetupMode func() bool + // RoleGate enforces per-function authorisation. Defaults to + // AllowAuthenticated. + RoleGate RoleGate + // Logger receives structured logs. Defaults to slog.Default(). + Logger *slog.Logger + // Now is the clock (tests). Defaults to time.Now. + Now func() time.Time + // RateLimit / RateWindow tune the auth-sensitive limiter (A3). + RateLimit int + RateWindow time.Duration +} + +// Server is the door. +type Server struct { + cfg *config.Config + disp *routing.Dispatcher + reg Registry + sessions auth.Sessions + ids auth.Identities + users users.Users + audit audit.Audit + csrf *httpx.CSRF + precsrf *httpx.PreSessionCSRF + trusted httpx.TrustedProxies + secure bool + setup func() bool + roles RoleGate + log *slog.Logger + now func() time.Time + limiter *middleware.RateLimiter +} + +// New validates the dependencies and builds the door. +func New(d Deps) (*Server, error) { + if d.Config == nil { + return nil, errors.New("server: Config is required") + } + if d.Resolver == nil { + return nil, errors.New("server: Resolver is required") + } + if d.Registry == nil { + return nil, errors.New("server: Registry is required") + } + if d.Sessions == nil { + return nil, errors.New("server: Sessions is required") + } + if d.Identities == nil { + return nil, errors.New("server: Identities is required") + } + if d.Users == nil { + return nil, errors.New("server: Users is required") + } + if d.CSRF == nil { + return nil, errors.New("server: CSRF is required") + } + + trusted := d.TrustedProxies + if trusted == nil { + var err error + trusted, err = httpx.ParseTrustedProxies(d.Config.Server.TrustedProxies) + if err != nil { + return nil, err + } + } + + setup := d.SetupMode + if setup == nil { + cfg := d.Config + setup = func() bool { return cfg.Setup } + } + logger := d.Logger + if logger == nil { + logger = slog.Default() + } + clock := d.Now + if clock == nil { + clock = time.Now + } + roles := d.RoleGate + if roles == nil { + roles = AllowAuthenticated{} + } + + return &Server{ + cfg: d.Config, + disp: routing.New(d.Resolver, d.Registry), + reg: d.Registry, + sessions: d.Sessions, + ids: d.Identities, + users: d.Users, + audit: d.Audit, + csrf: d.CSRF, + precsrf: d.PreSession, + trusted: trusted, + secure: d.Config.IsSecure(), + setup: setup, + roles: roles, + log: logger, + now: clock, + limiter: middleware.NewRateLimiter(d.RateLimit, d.RateWindow), + }, nil +} + +// Handler wraps the door with the correlation-id middleware (SPEC-003 §4). +func (s *Server) Handler() http.Handler { return middleware.CorrelationID(s) } + +// Serve binds listen and serves the door until it fails. In setup mode a +// non-loopback listen is refused outright (D-025, AC-7). +func (s *Server) Serve(listen string) error { + if err := CheckSetupBind(s.setup(), listen); err != nil { + return err + } + s.log.Info("myday: listening", "addr", listen, "setup_mode", s.setup()) + srv := &http.Server{ + Addr: listen, + Handler: s.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + return srv.ListenAndServe() +} + +// ServeHTTP is the door pipeline (SPEC-003 §4). +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + start := s.now() + + ctx := r.Context() + cid := middleware.CorrelationFrom(ctx) + if cid == "" { + cid = middleware.NewCorrelationID() + ctx = middleware.WithCorrelation(ctx, cid) + } + w.Header().Set(middleware.CorrelationHeader, cid) + + // 2. client IP (SET XFF, never append — A6). + ip := httpx.ClientIP(r, s.trusted) + + // 3. security headers. + httpx.SetSecurityHeaders(w, s.secure) + + // The door attaches audit.Meta before any service call (R-1). The actor is + // filled in once the identity gate resolves it. + ctx = middleware.AttachAudit(ctx, 0, ip, cid) + r = r.WithContext(ctx) + + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + var res routing.Resolution + defer func() { + s.log.Info("request", + "correlation_id", cid, + "method", r.Method, + "path", httpx.RedactPath(r.URL.Path), + "query", httpx.RedactQuery(r.URL.RawQuery), + "client_ip", ip, + "status", sw.status, + "workflow", res.Workflow, + "module", res.Module, + "function", res.Function, + "duration_ms", s.now().Sub(start).Milliseconds(), + ) + }() + + // 4. rate limit the auth-sensitive routes (A3, AC-9). + if class := middleware.Class(r.URL.Path); class != "" { + key := ip + "|" + class + if !s.limiter.Allow(key) { + if secs := s.limiter.RetryAfter(key); secs > 0 { + sw.Header().Set("Retry-After", strconv.Itoa(secs)) + } + s.writeError(sw, http.StatusTooManyRequests, "too many requests") + return + } + } + + // 5. the pure model. + res = s.disp.Dispatch(r.URL.Path, r.FormValue("do"), r.FormValue("action")) + + // 6. the Kind switch. + s.dispatch(sw, r, res, cid, ip) +} + +// dispatch is the Kind switch (SPEC-003 §4 step 5). +func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) { + // SPEC-005 §3: in setup mode the app serves ONLY /setup (+ static assets); + // every other route is 404 (delete, don't redirect). Reserved names like + // /home and /access are 404'd too — the bootstrap has no app yet. + if s.setup() && !setupAllowed(res) { + s.writeError(w, http.StatusNotFound, "not found") + return + } + + switch res.Kind { + case routing.KindRedirect: + // The ONE canonical redirect ("/" -> "/home"). + http.Redirect(w, r, res.Redirect, http.StatusFound) + + case routing.KindNotFound: + s.writeError(w, http.StatusNotFound, "not found") + + case routing.KindSetup: + // /setup is served ONLY in setup mode; otherwise it 404s (delete, + // don't redirect — SPEC-005 §5). + if !s.setup() { + s.writeError(w, http.StatusNotFound, "not found") + return + } + s.serveCore(w, r, "setup", res, cid, ip) + + case routing.KindEnroll: + // GUID found but not enrolled -> the enrollment screen (SPEC-006). + s.serveCore(w, r, "enroll", res, cid, ip) + + case routing.KindLogin: + s.serveCore(w, r, "login", res, cid, ip) + + case routing.KindRoute, routing.KindAction: + // /setup is setup-mode-only through BOTH doors — a path request + // classifies as KindSetup, but `do`/`action` under /setup classifies as + // KindAction with Workflow "setup". Productive mode 404s either way + // (delete, don't redirect — SPEC-005 §5). + if res.Workflow == "setup" && !s.setup() { + s.writeError(w, http.StatusNotFound, "not found") + return + } + // SPEC-005: the wizard's steps are actions under the reserved `setup` + // workflow. They run anonymously (pre-session CSRF, A15). + if res.Kind == routing.KindAction && res.Workflow == "setup" { + s.serveSetupAction(w, r, res, cid, ip) + return + } + // SPEC-006: the `auth` module's actions are the enroll/login flows. + // They run BEFORE the session gate (the whole point is to obtain a + // session); logout is the exception and stays session-gated. + if res.Kind == routing.KindAction && res.Module == authModule && + !routing.IsReservedWorkflow(res.Workflow) { + s.serveAuthAction(w, r, res, cid, ip) + return + } + // SPEC-007: the anonymous /access recovery action — the recovery + // request form (`do=auth`). It runs before any session (pre-session + // CSRF, A15) and is always a generic response (A4). Only the `auth` + // module is served here: the session-gated approve/deny actions must + // ride a GUID path and are deliberately NOT reachable at /access. + if res.Kind == routing.KindAction && res.Workflow == "access" && res.Module == authModule { + s.serveAccessAction(w, r, res, cid, ip) + return + } + // A reserved level-1 workflow (home · access · api · static · …) is + // core-owned and needs no identity gate. + if routing.IsReservedWorkflow(res.Workflow) { + s.serveCore(w, r, res.Workflow, res, cid, ip) + return + } + // Otherwise it is a GUID branch: the identity/session/role gate. + s.serveGUID(w, r, res, cid, ip) + + default: + s.writeError(w, http.StatusNotFound, "not found") + } +} + +// setupAllowed reports whether a Resolution is one of the only routes served +// while in setup mode: the wizard itself (`/setup`, path or action) and static +// assets (SPEC-005 §3). Everything else — including the canonical redirect and +// every GUID branch — is 404. +func setupAllowed(res routing.Resolution) bool { + switch res.Kind { + case routing.KindSetup: + return true + case routing.KindAction: + return res.Workflow == "setup" + case routing.KindRoute: + return res.Workflow == "static" + } + return false +} + +// serveCore hands off to a core-owned workflow handler. +func (s *Server) serveCore(w http.ResponseWriter, r *http.Request, workflow string, res routing.Resolution, cid, ip string) { + h, ok := s.reg.CoreHandler(workflow) + if !ok { + s.writeError(w, http.StatusNotFound, "not found") + return + } + s.invoke(w, r, h, newRequestContext(res, 0, cid, ip)) +} + +// serveAuthAction runs the anonymous enroll/login actions (SPEC-006 §4/§5). +// It runs BEFORE the identity/session gate — the whole point is to obtain a +// session — so it verifies the signed pre-session double-submit token (A15) +// instead of the session-bound one. `logout` is session-gated (serveGUID). +func (s *Server) serveAuthAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) { + if s.precsrf == nil { + s.writeError(w, http.StatusNotFound, "not found") + return + } + // `logout` revokes an existing session, and the SPEC-006 §8 passkey-nudge + // actions (passkey.add / passkey.later / passkey.never) require one — they + // are session-gated (SPEC-003 §6 CSRF), NOT anonymous like the enroll/login + // actions above. + if res.Function == "logout" || strings.HasPrefix(res.Function, "passkey.") { + s.serveGUID(w, r, res, cid, ip) + return + } + if !s.precsrf.Valid(r, r.FormValue("csrf")) { + s.writeError(w, http.StatusForbidden, "forbidden") + return + } + h, ok := s.reg.Handler(res.Module, res.Function) + if !ok { + s.writeError(w, http.StatusNotFound, "not found") + return + } + s.invoke(w, r, h, newRequestContext(res, 0, cid, ip)) +} + +// serveSetupAction runs a SPEC-005 wizard step. The wizard is anonymous — no +// session exists before the admin is created — so it verifies the signed +// pre-session double-submit token (A15) rather than the session-bound CSRF. The +// door has already gated this to setup mode. +func (s *Server) serveSetupAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) { + if !s.setup() { + s.writeError(w, http.StatusNotFound, "not found") + return + } + if s.precsrf == nil { + s.writeError(w, http.StatusNotFound, "not found") + return + } + if !s.precsrf.Valid(r, r.FormValue("csrf")) { + s.writeError(w, http.StatusForbidden, "forbidden") + return + } + h, ok := s.reg.Handler(res.Module, res.Function) + if !ok { + s.writeError(w, http.StatusNotFound, "not found") + return + } + s.invoke(w, r, h, newRequestContext(res, 0, cid, ip)) +} + +// serveAccessAction runs a SPEC-007 /access action. The recovery entry is +// anonymous — no session exists yet — so it verifies the signed pre-session +// double-submit token (A15) rather than the session-bound CSRF (§6). The +// response is generic whatever the email matches (AC-4). +func (s *Server) serveAccessAction(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) { + if s.precsrf == nil { + s.writeError(w, http.StatusNotFound, "not found") + return + } + if !s.precsrf.Valid(r, r.FormValue("csrf")) { + s.writeError(w, http.StatusForbidden, "forbidden") + return + } + h, ok := s.reg.Handler(res.Module, res.Function) + if !ok { + s.writeError(w, http.StatusNotFound, "not found") + return + } + s.invoke(w, r, h, newRequestContext(res, 0, cid, ip)) +} + +// serveGUID runs the identity/session/role gate, then dispatches to the module +// function (SPEC-003 §4 step 5). +func (s *Server) serveGUID(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) { + ctx := r.Context() + + // (a) load the session cookie; an absent or empty id is rejected (A2). + cookie, err := r.Cookie(httpx.SessionCookieName) + if err != nil || cookie.Value == "" { + s.loginRequired(w, r, res, cid, ip) + return + } + + // Validate: exists · not expired · not revoked · non-empty (A2/AC-5). + sess, err := s.sessions.Validate(ctx, cookie.Value) + if err != nil { + httpx.ClearSessionCookie(w, httpx.SessionCookieName, s.secure) + s.loginRequired(w, r, res, cid, ip) + return + } + + // Resolve the GUID's user and require an ACTIVE identity. + idn, err := s.ids.ByGUID(ctx, res.Workflow) + if err != nil || idn.Status != auth.StatusActive { + s.writeError(w, http.StatusNotFound, "not found") + return + } + + // (b) the identity match: the session must belong to the GUID's user + // (AC-4). The rejection is generic — no oracle about the session's owner. + if sess.UserID != idn.UserID { + s.loginRequired(w, r, res, cid, ip) + return + } + + // (d) the role gate. + u, err := s.users.Get(ctx, sess.UserID) + if err != nil { + s.writeError(w, http.StatusNotFound, "not found") + return + } + if !s.roles.Allowed(u.Role, res.Module, res.Function) { + s.writeError(w, http.StatusForbidden, "forbidden") + return + } + + // Attach the actor now that the identity is known (R-1). + ctx = middleware.AttachAudit(ctx, sess.UserID, ip, cid) + + // CSRF on every action (A8/AC-6). The routing fields are not a control. + if res.Kind == routing.KindAction { + if !s.csrf.Valid(sess.ID, r.FormValue("csrf")) { + s.writeError(w, http.StatusForbidden, "invalid csrf token") + return + } + } + + // (e) build the RequestContext and dispatch. + h, ok := s.reg.Handler(res.Module, res.Function) + if !ok { + s.writeError(w, http.StatusNotFound, "not found") + return + } + ctx = httpx.WithToken(ctx, s.csrf.Token(sess.ID)) + r = r.WithContext(ctx) + + s.invoke(w, r, h, newRequestContext(res, sess.UserID, cid, ip)) +} + +// loginRequired hands off to the login screen without revealing why the session +// was rejected (missing, empty, invalid, or belonging to another user). The +// screen is a RENDERED PAGE and answers 200; only a failed credential attempt is +// 401/400 (F-1). The 401 below is a degenerate fallback for a registry that +// carries no login screen at all. +func (s *Server) loginRequired(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) { + h, ok := s.reg.CoreHandler("login") + if !ok { + s.writeError(w, http.StatusUnauthorized, "login required") + return + } + s.invoke(w, r, h, newRequestContext(res, 0, cid, ip)) +} + +// invoke calls a handler and maps a returned error to a 500 (logged). +func (s *Server) invoke(w http.ResponseWriter, r *http.Request, h Handler, rc *RequestContext) { + if err := h(w, r, rc); err != nil { + s.log.Error("handler error", + "correlation_id", rc.Correlation, + "workflow", rc.Workflow, + "function", rc.Function, + "error", err, + ) + s.writeError(w, http.StatusInternalServerError, "internal error") + } +} + +// writeError writes a terse plain-text error response. +func (s *Server) writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _, _ = w.Write([]byte(msg + "\n")) +} + +// CheckSetupBind enforces D-025: while in setup mode the process MUST NOT bind +// a non-loopback address. It is fail-closed — an unparseable or wildcard listen +// is refused. +func CheckSetupBind(setup bool, listen string) error { + if !setup { + return nil + } + if !IsLoopback(listen) { + return fmt.Errorf("%w: listen %q", ErrNonLoopbackSetup, listen) + } + return nil +} + +// IsLoopback reports whether a listen address ("host:port") is loopback-only. +func IsLoopback(listen string) bool { + host := strings.TrimSpace(listen) + if h, _, err := net.SplitHostPort(listen); err == nil { + host = h + } + host = strings.Trim(strings.TrimSpace(host), "[]") + if host == "" { + return false // ":8080" / "0.0.0.0" bind every interface — never loopback + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// statusWriter captures the response status for the access log. +type statusWriter struct { + http.ResponseWriter + status int + wrote bool +} + +func (w *statusWriter) WriteHeader(code int) { + if w.wrote { + return + } + w.status = code + w.wrote = true + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + if !w.wrote { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(b) +} + +var _ http.Handler = (*Server)(nil) diff --git a/internal/core/server/server_test.go b/internal/core/server/server_test.go new file mode 100644 index 0000000..8b602ec --- /dev/null +++ b/internal/core/server/server_test.go @@ -0,0 +1,631 @@ +package server_test + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +// ── fakes / wrappers ───────────────────────────────────────────────────────── + +// The counting wrappers embed the real interface and increment a counter before +// delegating, so a test can prove "the door never touched this" (AC-2). +type countingResolver struct { + routing.Resolver + calls *int +} + +func (c countingResolver) GUID(g string) (bool, bool) { *c.calls++; return c.Resolver.GUID(g) } + +type countingSessions struct { + auth.Sessions + calls *int +} + +func (c countingSessions) Validate(ctx context.Context, id string) (auth.Session, error) { + *c.calls++ + return c.Sessions.Validate(ctx, id) +} + +type countingIdentities struct { + auth.Identities + calls *int +} + +func (c countingIdentities) ByGUID(ctx context.Context, g string) (auth.Identity, error) { + *c.calls++ + return c.Identities.ByGUID(ctx, g) +} + +type countingUsers struct { + users.Users + calls *int +} + +func (c countingUsers) Get(ctx context.Context, id int64) (users.User, error) { + *c.calls++ + return c.Users.Get(ctx, id) +} + +// probeCapture records what the door handed to a module function. +type probeCapture struct { + rc *server.RequestContext + meta audit.Meta + metaOK bool +} + +// ── registry ───────────────────────────────────────────────────────────────── + +func testRegistry(cap *probeCapture) *server.MapRegistry { + reg := server.NewMapRegistry() + + reg.RegisterCore("home", server.Home) + reg.RegisterCore("login", server.LoginPlaceholder) + reg.RegisterCore("enroll", server.EnrollPlaceholder) + reg.RegisterCore("setup", server.SetupPlaceholder) + reg.RegisterCore("access", server.AccessPlaceholder) + + reg.RegisterModule(server.NewModule("home"). + SetDefault("dashboard", server.Home). + AddRoute("home", "home", server.Home)) + + reg.RegisterModule(server.NewModule("hr"). + AddAction("save-data", "save-data", func(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("saved")) + return err + })) + + reg.RegisterModule(server.NewModule("probe"). + AddRoute("show", "show", func(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error { + if cap != nil { + cap.rc = rc + cap.meta, cap.metaOK = audit.MetaFrom(r.Context()) + } + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("probe")) + return err + })) + + return reg +} + +// ── harness ────────────────────────────────────────────────────────────────── + +type harness struct { + t *testing.T + ctx context.Context + db *store.DB + users *users.Store + ids *auth.IdentitiesService + sessions *auth.SessionsService + csrf *httpx.CSRF + pre *httpx.PreSessionCSRF + wa *fakeWebAuthn + totp *totp.Generator + srv *server.Server + + adminID int64 + guid string + session auth.Session +} + +func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness { + t.Helper() + ctx := context.Background() + + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + aud := audit.New(db.DB) + us := users.New(db.DB, aud) + + adminID, err := us.Create(ctx, 0, "admin@example.com", "Admin", users.RoleAdmin) + if err != nil { + t.Fatalf("bootstrap admin: %v", err) + } + + sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x42}, auth.KeySize)) + if err != nil { + t.Fatalf("NewAEADSealer: %v", err) + } + core := auth.New(db.DB, aud, auth.WithSealer(sealer)) + ids := core.Identities() + sessions := core.Sessions() + + idID, guid, err := ids.Mint(ctx, adminID) + if err != nil { + t.Fatalf("Mint: %v", err) + } + if err := ids.Activate(ctx, idID); err != nil { + t.Fatalf("Activate: %v", err) + } + sess, err := sessions.Mint(ctx, idID, "127.0.0.1", "test") + if err != nil { + t.Fatalf("session Mint: %v", err) + } + + const ( + csrfKey = "test-csrf-key-0123456789abcdef" + cookieSecret = "test-cookie-secret-0123456789abcdef" + ) + csrf, err := httpx.NewCSRF([]byte(csrfKey)) + if err != nil { + t.Fatalf("NewCSRF: %v", err) + } + pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false) + if err != nil { + t.Fatalf("NewPreSessionCSRF: %v", err) + } + + cfg := &config.Config{ + Setup: false, + Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "http://127.0.0.1:8080"}, + Database: config.Database{Path: db.Path()}, + Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret}, + } + + wa := &fakeWebAuthn{} + totpSvc := totp.New("MyDay", ids) + + deps := server.Deps{ + Config: cfg, + Resolver: ids, + Sessions: sessions, + Identities: ids, + Users: us, + Audit: aud, + CSRF: csrf, + PreSession: pre, + } + for _, o := range opts { + o(&deps) + } + + // Build the SPEC-006 flow with the (possibly overridden) logger + CSRF, and + // register it unless the test supplied its own registry. + authFlow, err := flow.New(flow.Deps{ + Identities: ids, + Credentials: ids, + Sessions: sessions, + Users: us, + WebAuthn: wa, + TOTP: totpSvc, + CSRF: deps.PreSession, + Audit: aud, + Logger: deps.Logger, + // The passkey nudge (SPEC-006 §8) is suppressed in setup mode; the + // door's SetupMode is the same predicate (nil = nudge enabled). + SuppressNudge: deps.SetupMode, + }) + if err != nil { + t.Fatalf("flow.New: %v", err) + } + if deps.Registry == nil { + reg := testRegistry(nil) + server.RegisterAuthFlow(reg, authFlow) + deps.Registry = reg + } + + srv, err := server.New(deps) + if err != nil { + t.Fatalf("server.New: %v", err) + } + + return &harness{ + t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions, + csrf: csrf, pre: pre, wa: wa, totp: totpSvc, srv: srv, + adminID: adminID, guid: guid, session: sess, + } +} + +// preCSRF mints a signed pre-session token plus its cookie, as the enroll/login +// screens would (A15). +func (h *harness) preCSRF() (*http.Cookie, string) { + h.t.Helper() + tok, err := h.pre.Token() + if err != nil { + h.t.Fatalf("pre-session Token: %v", err) + } + return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok +} + +func (h *harness) cookie() *http.Cookie { + return &http.Cookie{Name: httpx.SessionCookieName, Value: h.session.ID} +} + +// request issues a request directly against the door (no network). +func request(h http.Handler, method, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +// ── AC-1 ───────────────────────────────────────────────────────────────────── + +func TestAC1_RedirectAndNotFound(t *testing.T) { + h := newHarness(t) + + rec := request(h.srv, "GET", "/") + if rec.Code != http.StatusFound { + t.Fatalf("GET / = %d, want 302", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/home" { + t.Errorf("Location = %q, want /home", loc) + } + + rec = request(h.srv, "GET", "/holy-shit") + if rec.Code != http.StatusNotFound { + t.Errorf("GET /holy-shit = %d, want 404", rec.Code) + } +} + +// ── AC-2 ───────────────────────────────────────────────────────────────────── + +func TestAC2_HomeNoSessionNoDB(t *testing.T) { + var resolverCalls, validateCalls, byGUIDCalls, getCalls int + h := newHarness(t, func(d *server.Deps) { + d.Resolver = countingResolver{Resolver: d.Resolver, calls: &resolverCalls} + d.Sessions = countingSessions{Sessions: d.Sessions, calls: &validateCalls} + d.Identities = countingIdentities{Identities: d.Identities, calls: &byGUIDCalls} + d.Users = countingUsers{Users: d.Users, calls: &getCalls} + }) + + rec := request(h.srv, "GET", "/home") + if rec.Code != http.StatusOK { + t.Fatalf("GET /home = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "MyDay") { + t.Error("GET /home did not render the landing page") + } + + if resolverCalls != 0 || validateCalls != 0 || byGUIDCalls != 0 || getCalls != 0 { + t.Errorf("/home touched storage: resolver=%d sessions=%d identities=%d users=%d (AC-2)", + resolverCalls, validateCalls, byGUIDCalls, getCalls) + } +} + +// ── AC-3 ───────────────────────────────────────────────────────────────────── + +func TestAC3_GUIDWithAndWithoutSession(t *testing.T) { + h := newHarness(t) + + // No session -> the login screen (a rendered page: 200, F-1). + rec := request(h.srv, "GET", "/"+h.guid) + if rec.Code != http.StatusOK { + t.Errorf("GET /{guid} without a session = %d, want 200 (login screen, F-1)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Sign in") { + t.Error("GET /{guid} without a session did not render the login screen (AC-3)") + } + + // Valid session -> the module function (the dashboard). + rec = request(h.srv, "GET", "/"+h.guid, h.cookie()) + if rec.Code != http.StatusOK { + t.Fatalf("GET /{guid} with a session = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "MyDay") { + t.Error("the module function did not render the dashboard") + } + + // /{GUID}/home is the same user landing. + rec = request(h.srv, "GET", "/"+h.guid+"/home", h.cookie()) + if rec.Code != http.StatusOK { + t.Errorf("GET /{guid}/home with a session = %d, want 200", rec.Code) + } +} + +// ── AC-4 ───────────────────────────────────────────────────────────────────── + +func TestAC4_SessionUserMismatchRejected(t *testing.T) { + h := newHarness(t) + + // A second user with an active identity. + otherID, err := h.users.Create(h.ctx, h.adminID, "b@example.com", "B", users.RoleTeam) + if err != nil { + t.Fatalf("create other user: %v", err) + } + idOther, guidOther, err := h.ids.Mint(h.ctx, otherID) + if err != nil { + t.Fatalf("Mint other: %v", err) + } + if err := h.ids.Activate(h.ctx, idOther); err != nil { + t.Fatalf("Activate other: %v", err) + } + + // The admin's session must NOT open the other user's GUID: the door hands + // off to the login screen (200, a rendered page — F-1), never the module. + rec := request(h.srv, "GET", "/"+guidOther, h.cookie()) + if rec.Code != http.StatusOK { + t.Errorf("GET /{other-guid} with a foreign session = %d, want 200 (login screen, AC-4)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Sign in") { + t.Error("a foreign session did not land on the login screen (AC-4)") + } + // The dashboard marker proves the module function was never reached (the + // shell's own chrome carries "MyDay" on every page, login included). + if strings.Contains(rec.Body.String(), "Your day, at a glance.") { + t.Error("a foreign session reached the module function (AC-4)") + } +} + +// ── AC-5 ───────────────────────────────────────────────────────────────────── + +func TestAC5_EmptyOrMissingSessionRejected(t *testing.T) { + h := newHarness(t) + + empty := &http.Cookie{Name: httpx.SessionCookieName, Value: ""} + cases := []struct { + name string + target string + cookie *http.Cookie + }{ + {"missing id on /{guid}", "/" + h.guid, nil}, + {"empty id on /{guid}", "/" + h.guid, empty}, + {"missing id on /{guid}/home", "/" + h.guid + "/home", nil}, + {"empty id on /{guid}/home", "/" + h.guid + "/home", empty}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var rec *httptest.ResponseRecorder + if tc.cookie == nil { + rec = request(h.srv, "GET", tc.target) + } else { + rec = request(h.srv, "GET", tc.target, tc.cookie) + } + // The door rejects the empty/missing session and renders the login + // screen (200, F-1) — it must never reach the module. + if rec.Code != http.StatusOK { + t.Errorf("%s = %d, want 200 (login screen, A2/F-1)", tc.target, rec.Code) + } + if !strings.Contains(rec.Body.String(), "Sign in") { + t.Errorf("%s did not render the login screen (A2)", tc.target) + } + }) + } +} + +// ── AC-6 ───────────────────────────────────────────────────────────────────── + +func TestAC6_ActionRequiresCSRF(t *testing.T) { + h := newHarness(t) + base := "/" + h.guid + "?do=hr&action=save-data" + + // No CSRF token -> rejected. + rec := request(h.srv, "POST", base, h.cookie()) + if rec.Code != http.StatusForbidden { + t.Errorf("action without a CSRF token = %d, want 403 (AC-6)", rec.Code) + } + + // A wrong token -> rejected. + rec = request(h.srv, "POST", base+"&csrf=wrong", h.cookie()) + if rec.Code != http.StatusForbidden { + t.Errorf("action with a bad CSRF token = %d, want 403", rec.Code) + } + + // The correct, session-bound token -> dispatched. + token := url.QueryEscape(h.csrf.Token(h.session.ID)) + rec = request(h.srv, "POST", base+"&csrf="+token, h.cookie()) + if rec.Code != http.StatusOK { + t.Fatalf("action with a valid CSRF token = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "saved") { + t.Error("the action handler did not run") + } +} + +// ── AC-7 ───────────────────────────────────────────────────────────────────── + +func TestAC7_LoopbackEnforcement(t *testing.T) { + cases := []struct { + setup bool + listen string + want bool // want an error? + }{ + {true, "0.0.0.0:8080", true}, + {true, ":8080", true}, + {true, "192.168.1.5:8080", true}, + {true, "127.0.0.1:8080", false}, + {true, "localhost:8080", false}, + {true, "[::1]:8080", false}, + {false, "0.0.0.0:8080", false}, // productive: a public bind is allowed + } + for _, tc := range cases { + err := server.CheckSetupBind(tc.setup, tc.listen) + if tc.want && !errors.Is(err, server.ErrNonLoopbackSetup) { + t.Errorf("CheckSetupBind(%v, %q) = %v, want ErrNonLoopbackSetup", tc.setup, tc.listen, err) + } + if !tc.want && err != nil { + t.Errorf("CheckSetupBind(%v, %q) = %v, want nil", tc.setup, tc.listen, err) + } + } + + // The full Serve path refuses to bind too (no listener is opened). + h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } }) + if err := h.srv.Serve("0.0.0.0:0"); !errors.Is(err, server.ErrNonLoopbackSetup) { + t.Errorf("Serve with a public listen in setup mode = %v, want ErrNonLoopbackSetup (AC-7)", err) + } +} + +// ── AC-8 ───────────────────────────────────────────────────────────────────── + +func TestAC8_CorrelationAndRedaction(t *testing.T) { + var logs bytes.Buffer + h := newHarness(t, func(d *server.Deps) { + d.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + }) + + rec := request(h.srv, "GET", "/home") + cid := rec.Header().Get(middleware.CorrelationHeader) + if cid == "" { + t.Fatal("the response carries no correlation id (AC-8)") + } + if !strings.Contains(logs.String(), cid) { + t.Errorf("the log does not echo the correlation id %q (AC-8)", cid) + } + + // A credential riding the path is redacted in the log. + logs.Reset() + request(h.srv, "GET", "/setup/token/SUPERSECRETTOKEN") + out := logs.String() + if strings.Contains(out, "SUPERSECRETTOKEN") { + t.Error("the access log leaked a path credential (A8/AC-8)") + } + if !strings.Contains(out, httpx.Redacted) { + t.Errorf("the access log did not mark the redaction (%q)", out) + } +} + +// ── AC-9 ───────────────────────────────────────────────────────────────────── + +func TestAC9_RateLimiterOnAuthRoutes(t *testing.T) { + h := newHarness(t, func(d *server.Deps) { + d.RateLimit = 2 + d.RateWindow = time.Minute + }) + + for i := 0; i < 2; i++ { + if rec := request(h.srv, "GET", "/access"); rec.Code != http.StatusOK { + t.Fatalf("request %d = %d, want 200", i+1, rec.Code) + } + } + rec := request(h.srv, "GET", "/access") + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("third request = %d, want 429 (AC-9)", rec.Code) + } + if rec.Header().Get("Retry-After") == "" { + t.Error("429 is missing a Retry-After header") + } +} + +// ── supporting behaviour ───────────────────────────────────────────────────── + +// TestRequestContextHandover proves §5: the module function receives the +// resolved identification, and the door attached audit.Meta (R-1). +func TestRequestContextHandover(t *testing.T) { + var cap probeCapture + h := newHarness(t, func(d *server.Deps) { d.Registry = testRegistry(&cap) }) + + rec := request(h.srv, "GET", "/"+h.guid+"/probe/show", h.cookie()) + if rec.Code != http.StatusOK { + t.Fatalf("probe/show = %d, want 200", rec.Code) + } + if cap.rc == nil { + t.Fatal("the module function was never invoked") + } + if cap.rc.UserID != h.adminID { + t.Errorf("rc.UserID = %d, want %d", cap.rc.UserID, h.adminID) + } + if want := "user:" + strconv.FormatInt(h.adminID, 10); cap.rc.Principal != want { + t.Errorf("rc.Principal = %q, want %q", cap.rc.Principal, want) + } + if cap.rc.Workflow != h.guid { + t.Errorf("rc.Workflow = %q, want the GUID %q", cap.rc.Workflow, h.guid) + } + if cap.rc.Function != "show" { + t.Errorf("rc.Function = %q, want show", cap.rc.Function) + } + if cap.rc.Correlation == "" || cap.rc.ClientIP == "" { + t.Errorf("rc correlation/client IP not set: %+v", cap.rc) + } + + if !cap.metaOK { + t.Fatal("audit.Meta was not attached to the context (R-1)") + } + if cap.meta.ActorUserID != h.adminID { + t.Errorf("meta.ActorUserID = %d, want %d", cap.meta.ActorUserID, h.adminID) + } + if cap.meta.CorrelationID != cap.rc.Correlation { + t.Errorf("meta.CorrelationID = %q, want %q", cap.meta.CorrelationID, cap.rc.Correlation) + } +} + +// TestSetupModeRouting: /setup is served only in setup mode (SPEC-003 §4). +func TestSetupModeRouting(t *testing.T) { + productive := newHarness(t) + if rec := request(productive.srv, "GET", "/setup"); rec.Code != http.StatusNotFound { + t.Errorf("GET /setup in productive mode = %d, want 404", rec.Code) + } + // The action door must also 404 /setup in productive mode. + if rec := request(productive.srv, "POST", "/setup?do=setup&action=begin"); rec.Code != http.StatusNotFound { + t.Errorf("POST /setup?do=setup in productive mode = %d, want 404", rec.Code) + } + + inSetup := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } }) + if rec := request(inSetup.srv, "GET", "/setup"); rec.Code != http.StatusOK { + t.Errorf("GET /setup in setup mode = %d, want 200", rec.Code) + } +} + +// TestEnrollRouting: a pending identity routes to the enrollment screen. +func TestEnrollRouting(t *testing.T) { + h := newHarness(t) + + pendingUser, err := h.users.Create(h.ctx, h.adminID, "pending@example.com", "P", users.RoleTeam) + if err != nil { + t.Fatalf("create pending user: %v", err) + } + _, guidPending, err := h.ids.Mint(h.ctx, pendingUser) // pending_enrollment + if err != nil { + t.Fatalf("Mint pending: %v", err) + } + + rec := request(h.srv, "GET", "/"+guidPending) + if rec.Code != http.StatusOK { + t.Fatalf("GET /{pending-guid} = %d, want 200 (enroll)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Enrollment") { + t.Error("a pending GUID did not route to the enrollment screen") + } +} + +// TestTrustedProxyClientIPAtTheDoor: only a trusted peer's XFF is honoured. +func TestTrustedProxyClientIPAtTheDoor(t *testing.T) { + h := newHarness(t, func(d *server.Deps) { + d.Config.Server.TrustedProxies = []string{"127.0.0.1"} + }) + + req := httptest.NewRequest("GET", "/home", nil) + req.RemoteAddr = "127.0.0.1:9999" + req.Header.Set("X-Forwarded-For", "203.0.113.77") + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := req.Header.Get("X-Forwarded-For"); got != "203.0.113.77" { + t.Errorf("X-Forwarded-For = %q, want the client value SET (never appended)", got) + } +} diff --git a/internal/core/server/setup_wizard_test.go b/internal/core/server/setup_wizard_test.go new file mode 100644 index 0000000..ab14504 --- /dev/null +++ b/internal/core/server/setup_wizard_test.go @@ -0,0 +1,592 @@ +package server_test + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + pqotp "github.com/pquerna/otp/totp" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +// ── harness: a FRESH instance (no user) wired to the real SPEC-005 wizard ───── + +type setupHarness struct { + t *testing.T + ctx context.Context + db *store.DB + users *users.Store + ids *auth.IdentitiesService + sessions *auth.SessionsService + csrf *httpx.CSRF + pre *httpx.PreSessionCSRF + wa *fakeWebAuthn + totpSvc *totp.Generator + + cfg *config.Config + cfgPath string + prints *bytes.Buffer + // writeFile overrides the persister (a failing writer exercises the + // "print the values" fallback, SPEC-005 §4). + writeFile func(string, []byte) error + + setupFlow *setup.Actions + srv *server.Server +} + +func newSetupHarness(t *testing.T, opts ...func(*setupHarness)) *setupHarness { + t.Helper() + ctx := context.Background() + dir := t.TempDir() + + db, err := store.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + aud := audit.New(db.DB) + us := users.New(db.DB, aud) // NO user created — a fresh instance. + + sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x24}, auth.KeySize)) + if err != nil { + t.Fatalf("NewAEADSealer: %v", err) + } + core := auth.New(db.DB, aud, auth.WithSealer(sealer)) + ids := core.Identities() + sessions := core.Sessions() + + const ( + csrfKey = "setup-csrf-key-0123456789abcdef" + cookieSecret = "setup-cookie-secret-0123456789abcdef" + ) + csrf, err := httpx.NewCSRF([]byte(csrfKey)) + if err != nil { + t.Fatalf("NewCSRF: %v", err) + } + pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false) + if err != nil { + t.Fatalf("NewPreSessionCSRF: %v", err) + } + + cfg := &config.Config{ + Setup: true, + Server: config.Server{Listen: "127.0.0.1:8080"}, + Database: config.Database{Path: db.Path()}, + Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret}, + } + + h := &setupHarness{ + t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions, + csrf: csrf, pre: pre, wa: &fakeWebAuthn{}, totpSvc: totp.New("MyDay", ids), + cfg: cfg, cfgPath: filepath.Join(dir, config.FileName), prints: &bytes.Buffer{}, + } + for _, o := range opts { + o(h) + } + + // Setup mode (SPEC-005 §3), latched: config.setup OR not yet bootstrapped. + // Latched at startup so the wizard can finish in-process; cleared by Done(). + bootstrapped := func() bool { + n, err := us.Count(context.Background()) + if err != nil || n == 0 { + return false + } + idn, err := ids.ByUserID(context.Background(), 1) + if err != nil { + return false + } + return idn.Status == auth.StatusActive + } + inSetup := cfg.Setup || !bootstrapped() + setupMode := func() bool { + if h.setupFlow != nil && h.setupFlow.Done() { + return false + } + return inSetup + } + + // The SPEC-006 flow, reused by the wizard. TOTPOnlyEnroll is the wizard's + // D-041 mode (setup enrolls TOTP only); SuccessPath keeps the operator on + // the wizard until it is finished. + authFlow, err := flow.New(flow.Deps{ + Identities: ids, Credentials: ids, Sessions: sessions, Users: us, + WebAuthn: h.wa, TOTP: h.totpSvc, CSRF: pre, Audit: aud, + TOTPOnlyEnroll: true, + SuppressNudge: setupMode, + SuccessPath: func(string) string { + if setupMode() { + return "/setup" + } + return "" + }, + }) + if err != nil { + t.Fatalf("flow.New: %v", err) + } + + setupFlow, err := setup.New(setup.Deps{ + Config: cfg, ConfigPath: h.cfgPath, Users: us, Identities: ids, + Enroll: authFlow, Audit: aud, CSRF: pre, + Printout: h.prints, WriteFile: h.writeFile, + }) + if err != nil { + t.Fatalf("setup.New: %v", err) + } + h.setupFlow = setupFlow + + reg := server.NewMapRegistry() + reg.RegisterCore("home", server.Home) + reg.RegisterCore("access", server.AccessPlaceholder) + reg.RegisterModule(server.NewModule("home"). + SetDefault("dashboard", server.Home). + AddRoute("home", "home", server.Home)) + server.RegisterSetupFlow(reg, setupFlow) + + srv, err := server.New(server.Deps{ + Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions, + Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre, + SetupMode: setupMode, + }) + if err != nil { + t.Fatalf("server.New: %v", err) + } + h.srv = srv + return h +} + +// preCSRF mints a signed pre-session token plus its cookie (A15). +func (h *setupHarness) preCSRF() (*http.Cookie, string) { + h.t.Helper() + tok, err := h.pre.Token() + if err != nil { + h.t.Fatalf("pre-session Token: %v", err) + } + return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok +} + +// post issues a wizard step with a fresh pre-session CSRF token. +func (h *setupHarness) post(action string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { + h.t.Helper() + cookie, token := h.preCSRF() + if form == nil { + form = url.Values{} + } + form.Set("csrf", token) + + req := httptest.NewRequest("POST", "/setup?do=setup&action="+url.QueryEscape(action), + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + return rec +} + +// beginAdmin runs steps 1 and 2a: the domain, then the admin's details. +func (h *setupHarness) beginAdmin(t *testing.T) { + t.Helper() + if rec := h.post("domain.save", url.Values{"base_url": {"http://127.0.0.1:8080"}}); rec.Code != http.StatusSeeOther { + t.Fatalf("domain.save = %d, want 303", rec.Code) + } + if rec := h.post("admin.create", url.Values{ + "display_name": {"Claus"}, "email": {"admin@example.com"}, + }); rec.Code != http.StatusSeeOther { + t.Fatalf("admin.create = %d, want 303", rec.Code) + } +} + +// enrollAdmin runs step 2b — the TOTP-only ceremony (D-041) — and returns the +// session id the flow minted on completion (SPEC-006 reused). +func (h *setupHarness) enrollAdmin(t *testing.T) string { + t.Helper() + + if rec := h.post("admin.totp.begin", nil); rec.Code != http.StatusOK { + t.Fatalf("admin.totp.begin = %d, want 200", rec.Code) + } + + secret, err := h.ids.TOTPSecret(h.ctx, 1) + if err != nil { + t.Fatalf("TOTPSecret: %v", err) + } + code, err := pqotp.GenerateCode(string(secret), time.Now()) + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + done := h.post("admin.totp.verify", url.Values{"code": {code}}) + if done.Code != http.StatusSeeOther { + t.Fatalf("admin.totp.verify = %d, want 303 (enrolled)", done.Code) + } + // The wizard reuses the flow's SuccessPath so the admin stays on /setup. + if loc := done.Header().Get("Location"); loc != "/setup" { + t.Errorf("post-enroll Location = %q, want /setup", loc) + } + return sessionCookie(t, done) +} + +// ── AC-1 ───────────────────────────────────────────────────────────────────── + +func TestSpec005AC1_SetupModeServesOnlySetup(t *testing.T) { + h := newSetupHarness(t) + + rec := request(h.srv, "GET", "/setup") + if rec.Code != http.StatusOK { + t.Fatalf("GET /setup on a fresh instance = %d, want 200 (AC-1)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "MyDay setup") { + t.Error("GET /setup did not render the wizard (AC-1)") + } + // The wizard issues the pre-session CSRF cookie (A15). + if !hasCookie(rec, httpx.PreSessionCookieName) { + t.Error("the wizard issued no pre-session CSRF cookie (A15)") + } + + // EVERY other route is 404 — including the app the productive instance + // would serve (/home, /access, the canonical redirect, GUID branches). + for _, p := range []string{"/home", "/access", "/", "/holy-shit", "/some-guid", "/home/extra"} { + if rec := request(h.srv, "GET", p); rec.Code != http.StatusNotFound { + t.Errorf("GET %s in setup mode = %d, want 404 (AC-1)", p, rec.Code) + } + } +} + +// ── R-6 · generated secrets ────────────────────────────────────────────────── + +// TestR6_WizardGeneratesStrongSecrets: a first boot from config.example.yaml +// (all secrets are placeholders) must persist a VIABLE productive config — the +// wizard generates strong replacements (R-6), and the result reloads through +// the strict loader. +func TestR6_WizardGeneratesStrongSecrets(t *testing.T) { + h := newSetupHarness(t, func(h *setupHarness) { + h.cfg.Secrets = config.Secrets{ + SessionKey: "CHANGE_ME_32_BYTES_RANDOM", + CookieSecret: "CHANGE_ME_32_BYTES_RANDOM", + CSRFKey: "CHANGE_ME_32_BYTES_RANDOM", + TOTPEncryptionKey: "CHANGE_ME_32_BYTES_RANDOM", + } + }) + h.beginAdmin(t) + h.enrollAdmin(t) + if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther { + t.Fatalf("finish = %d, want 303", rec.Code) + } + + data, err := os.ReadFile(h.cfgPath) + if err != nil { + t.Fatalf("config was not persisted: %v", err) + } + if strings.Contains(string(data), "CHANGE_ME") { + t.Error("the persisted config still holds a placeholder secret (R-6)") + } + + cfg, err := config.Load(h.cfgPath) + if err != nil { + t.Fatalf("the generated config does not load (R-6): %v", err) + } + for name, v := range map[string]string{ + "session_key": cfg.Secrets.SessionKey, + "cookie_secret": cfg.Secrets.CookieSecret, + "csrf_key": cfg.Secrets.CSRFKey, + "totp_encryption_key": cfg.Secrets.TOTPEncryptionKey, + } { + if config.SecretIsPlaceholder(v) { + t.Errorf("%s is still a placeholder after setup (R-6)", name) + } + if len(v) < 32 { + t.Errorf("%s is only %d bytes, want >= 32 (R-6)", name, len(v)) + } + } + // The TOTP key feeds AES-256-GCM: exactly 32 bytes. + if n := len(cfg.Secrets.TOTPEncryptionKey); n != 32 { + t.Errorf("totp_encryption_key = %d bytes, want exactly 32 (R-6)", n) + } +} + +// ── AC-2 ───────────────────────────────────────────────────────────────────── + +func TestSpec005AC2_NonLoopbackRefusedInSetupMode(t *testing.T) { + h := newSetupHarness(t) + + // The process refuses to START on a non-loopback listen (D-025). + if err := h.srv.Serve("0.0.0.0:8080"); !errors.Is(err, server.ErrNonLoopbackSetup) { + t.Errorf("Serve(0.0.0.0:8080) in setup mode = %v, want ErrNonLoopbackSetup (AC-2)", err) + } + if err := h.srv.Serve("192.168.1.5:8080"); !errors.Is(err, server.ErrNonLoopbackSetup) { + t.Errorf("Serve(192.168.1.5:8080) in setup mode = %v, want ErrNonLoopbackSetup (AC-2)", err) + } + // Loopback is allowed (no error, and no listener is opened for :0). + if err := server.CheckSetupBind(true, "127.0.0.1:8080"); err != nil { + t.Errorf("CheckSetupBind(loopback) = %v, want nil", err) + } +} + +// ── AC-3 ───────────────────────────────────────────────────────────────────── + +func TestSpec005AC3_CompletingTheWizardCreatesTheTopAdmin(t *testing.T) { + h := newSetupHarness(t) + h.beginAdmin(t) + + // Step 2b renders the TOTP-only enrollment ceremony (D-041) — the passkey + // is deliberately NOT offered here. + page := request(h.srv, "GET", "/setup") + if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "admin.totp.verify") { + t.Errorf("the enrollment step did not render the TOTP form (AC-3): %d", page.Code) + } + if strings.Contains(page.Body.String(), "admin.enroll.begin") { + t.Error("the wizard still offers passkey enrollment (D-041, AC-3)") + } + h.enrollAdmin(t) + + u, err := h.users.Get(h.ctx, 1) + if err != nil { + t.Fatalf("user 1 was not created: %v", err) + } + if u.Role != users.RoleAdmin { + t.Errorf("user 1 role = %q, want admin (AC-3)", u.Role) + } + if u.Manager.Set { + t.Error("the top admin must be the root of the chain (no manager, AC-3)") + } + + idn, err := h.ids.ByUserID(h.ctx, 1) + if err != nil { + t.Fatalf("ByUserID(1): %v", err) + } + if idn.Status != auth.StatusActive { + t.Errorf("the admin's identity = %s, want active (AC-3)", idn.Status) + } + // D-041/AC-3: TOTP is enrolled, and NO passkey is — the admin adds one + // later at first sign-in on the real origin, via the nudge (SPEC-006 §8). + if hasPasskey, err := h.ids.HasWebAuthnCredential(h.ctx, idn.ID); err != nil || hasPasskey { + t.Errorf("the wizard enrolled a passkey (D-041, AC-3): %v", err) + } + secret, err := h.ids.TOTPSecret(h.ctx, idn.ID) + if err != nil || len(secret) == 0 { + t.Errorf("the admin has no TOTP secret (AC-3): %v", err) + } +} + +// ── AC-4 ───────────────────────────────────────────────────────────────────── + +func TestSpec005AC4_CompletionPersistsAndSetupDisappears(t *testing.T) { + h := newSetupHarness(t) + h.beginAdmin(t) + h.enrollAdmin(t) + + rec := h.post("finish", url.Values{ + "mail.host": {"smtp.example.com"}, "mail.port": {"587"}, + "mail.username": {"myday"}, "mail.from": {"myday@example.com"}, + "app.locale": {"en-GB"}, "app.timezone": {"Europe/London"}, + }) + if rec.Code != http.StatusSeeOther { + t.Fatalf("finish = %d, want 303", rec.Code) + } + + if !h.setupFlow.Done() { + t.Error("the wizard did not signal completion (AC-4)") + } + + // The settings were persisted to config.yaml (D-035). + data, err := os.ReadFile(h.cfgPath) + if err != nil { + t.Fatalf("config was not persisted to %s: %v", h.cfgPath, err) + } + got := string(data) + for _, want := range []string{ + "setup: false", + "base_url: http://127.0.0.1:8080", + "locale: en-GB", + "timezone: Europe/London", + "smtp.example.com", + } { + if !strings.Contains(got, want) { + t.Errorf("persisted config is missing %q (AC-4)", want) + } + } + + // /setup now 404s — through BOTH doors (delete, don't redirect). + if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound { + t.Errorf("GET /setup after completion = %d, want 404 (AC-4)", rec.Code) + } + if rec := h.post("finish", nil); rec.Code != http.StatusNotFound { + t.Errorf("POST /setup action after completion = %d, want 404 (AC-4)", rec.Code) + } +} + +// TestSpec005AC4_UnwritableConfigPrintsTheValues: when config.yaml is not +// writable the wizard prints the settings for the operator and still completes. +func TestSpec005AC4_UnwritableConfigPrintsTheValues(t *testing.T) { + h := newSetupHarness(t, func(h *setupHarness) { + h.writeFile = func(string, []byte) error { return errors.New("read-only file system") } + }) + h.beginAdmin(t) + h.enrollAdmin(t) + + if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther { + t.Fatalf("finish = %d, want 303", rec.Code) + } + if !h.setupFlow.Done() { + t.Error("setup was not marked complete despite the printed fallback (AC-4)") + } + if !strings.Contains(h.prints.String(), "setup: false") { + t.Error("the values were not printed for the operator (AC-4)") + } + if _, err := os.Stat(h.cfgPath); !errors.Is(err, os.ErrNotExist) { + t.Error("a config file was written even though the writer failed (AC-4)") + } + if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound { + t.Errorf("GET /setup after the printed completion = %d, want 404 (AC-4)", rec.Code) + } +} + +// ── AC-5 ───────────────────────────────────────────────────────────────────── + +func TestSpec005AC5_OnlyOneAdmin(t *testing.T) { + h := newSetupHarness(t) + h.beginAdmin(t) // creates user 1 + + rec := h.post("admin.create", url.Values{ + "display_name": {"Second"}, "email": {"second@example.com"}, + }) + if rec.Code != http.StatusConflict { + t.Errorf("a second admin.create = %d, want 409 (AC-5)", rec.Code) + } + + // The service itself refuses a second admin, whoever asks (SPEC-004 AC-6). + if _, err := h.users.Create(h.ctx, 1, "third@example.com", "Third", users.RoleAdmin); !errors.Is(err, users.ErrOneAdmin) { + t.Errorf("Create(role=admin) with user 1 present = %v, want ErrOneAdmin (AC-5)", err) + } + + n, err := h.users.Count(h.ctx) + if err != nil { + t.Fatalf("Count: %v", err) + } + if n != 1 { + t.Errorf("user count = %d, want exactly 1 (AC-5)", n) + } +} + +// ── AC-6 ───────────────────────────────────────────────────────────────────── + +func TestSpec005AC6_TheBootstrapIsAudited(t *testing.T) { + h := newSetupHarness(t) + h.beginAdmin(t) + h.enrollAdmin(t) + if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther { + t.Fatalf("finish = %d, want 303", rec.Code) + } + + rows, err := h.db.Query(`SELECT action FROM audit_log`) + if err != nil { + t.Fatalf("read audit_log: %v", err) + } + defer rows.Close() + + got := map[string]bool{} + for rows.Next() { + var a string + if err := rows.Scan(&a); err != nil { + t.Fatalf("scan audit action: %v", err) + } + got[a] = true + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate audit_log: %v", err) + } + + // The whole bootstrap: the admin's creation, its identity, the TOTP + // enrollment (D-041: no passkey), and the completion of setup (AC-6). + for _, want := range []string{ + "user.create", "identity.mint", + "enroll.totp.begin", "enroll.complete", + "setup.admin", "setup.complete", + } { + if !got[want] { + t.Errorf("audit log is missing %q (AC-6); got %v", want, got) + } + } +} + +// TestSpec005AdminCreateRepairsMissingIdentity: a crash between Create and Mint +// must not wedge the wizard — the next admin.create mints the missing identity. +func TestSpec005AdminCreateRepairsMissingIdentity(t *testing.T) { + h := newSetupHarness(t) + + // The user exists (user 1) but has NO identity. + if _, err := h.users.Create(h.ctx, 0, "admin@example.com", "Claus", users.RoleAdmin); err != nil { + t.Fatalf("create admin: %v", err) + } + if _, err := h.ids.ByUserID(h.ctx, 1); !errors.Is(err, auth.ErrIdentityNotFound) { + t.Fatalf("precondition: identity exists already (%v)", err) + } + + rec := h.post("admin.create", url.Values{"display_name": {"Claus"}, "email": {"admin@example.com"}}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("admin.create (repair) = %d, want 303", rec.Code) + } + idn, err := h.ids.ByUserID(h.ctx, 1) + if err != nil { + t.Fatalf("the admin identity was not repaired: %v", err) + } + if idn.Status != auth.StatusPendingEnrollment { + t.Errorf("repaired identity = %s, want pending_enrollment", idn.Status) + } +} + +// TestSpec005FailSafeSetupMode: with `config.setup: false` and an empty +// database the instance is STILL in setup mode (fail-safe) — and it stays there +// until the wizard finishes, even though creating the admin makes a live +// "no user exists" check false. +func TestSpec005FailSafeSetupMode(t *testing.T) { + h := newSetupHarness(t, func(h *setupHarness) { h.cfg.Setup = false }) + + // Fresh: setup mode via the fail-safe clause. + if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK { + t.Fatalf("GET /setup (fail-safe) = %d, want 200", rec.Code) + } + if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound { + t.Fatalf("GET /home (fail-safe) = %d, want 404", rec.Code) + } + + // Create the admin (now a user exists) and enroll — setup mode must HOLD. + h.beginAdmin(t) + if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK { + t.Fatalf("GET /setup after admin.create = %d, want 200 (setup must hold mid-wizard)", rec.Code) + } + if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound { + t.Fatalf("GET /home after admin.create = %d, want 404 (setup must hold mid-wizard)", rec.Code) + } + h.enrollAdmin(t) + + // Finish ends setup mode. + if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther { + t.Fatalf("finish = %d, want 303", rec.Code) + } + if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound { + t.Errorf("GET /setup after finish = %d, want 404", rec.Code) + } +} diff --git a/internal/core/server/setupflow.go b/internal/core/server/setupflow.go new file mode 100644 index 0000000..4d20adc --- /dev/null +++ b/internal/core/server/setupflow.go @@ -0,0 +1,38 @@ +package server + +import ( + "net/http" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup" +) + +// setupModule is the `do` module name for the SPEC-005 wizard steps. The +// wizard is a reserved level-1 workflow (`/setup`) whose steps are actions. +const setupModule = "setup" + +// RegisterSetupFlow registers the SPEC-005 `/setup` wizard: the page (the core +// `setup` workflow) and the action module (domain.save · admin.create · +// admin.enroll.* · admin.totp.* · finish). The door gates them to setup mode +// and verifies the pre-session CSRF token (A15) before dispatch. +func RegisterSetupFlow(reg *MapRegistry, a *setup.Actions) { + reg.RegisterCore("setup", adaptSetup(a.Page)) + + reg.RegisterModule(NewModule(setupModule). + AddAction("domain.save", "domain.save", adaptSetup(a.DomainSave)). + AddAction("admin.create", "admin.create", adaptSetup(a.AdminCreate)). + // Step 2 is TOTP only (D-041) — there is no admin.enroll.* action. + AddAction("admin.totp.begin", "admin.totp.begin", adaptSetup(a.TOTPBegin)). + AddAction("admin.totp.verify", "admin.totp.verify", adaptSetup(a.TOTPVerify)). + AddAction("finish", "finish", adaptSetup(a.Finish))) +} + +// setupFunc is a wizard handler. The door resolves the workflow; the wizard +// never parses a raw path. +type setupFunc func(w http.ResponseWriter, r *http.Request) error + +// adaptSetup adapts a wizard handler to the door's Handler signature. +func adaptSetup(f setupFunc) Handler { + return func(w http.ResponseWriter, r *http.Request, _ *RequestContext) error { + return f(w, r) + } +} diff --git a/internal/core/server/spec006_passkey_nudge_test.go b/internal/core/server/spec006_passkey_nudge_test.go new file mode 100644 index 0000000..86fdf21 --- /dev/null +++ b/internal/core/server/spec006_passkey_nudge_test.go @@ -0,0 +1,263 @@ +package server_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + pqotp "github.com/pquerna/otp/totp" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" +) + +// ── SPEC-006 §8 · the passkey nudge (D-041) · AC-8…AC-11 ───────────────────── + +// issueTOTP issues a TOTP secret for an identity and returns a valid code. +func (h *harness) issueTOTP(t *testing.T, identityID int64) string { + t.Helper() + if _, err := h.totp.Begin(h.ctx, identityID, h.guid); err != nil { + t.Fatalf("totp.Begin: %v", err) + } + secret, err := h.ids.TOTPSecret(h.ctx, identityID) + if err != nil { + t.Fatalf("TOTPSecret: %v", err) + } + code, err := pqotp.GenerateCode(string(secret), time.Now()) + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + return code +} + +// totpLogin performs an anonymous TOTP login for guid. host overrides the +// request Host (the browser origin); "" keeps the httptest default. +func (h *harness) totpLogin(t *testing.T, guid, code, host string) *httptest.ResponseRecorder { + t.Helper() + cookie, token := h.preCSRF() + form := url.Values{"csrf": {token}, "code": {code}} + req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action=totp.verify", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if host != "" { + req.Host = host + } + req.AddCookie(cookie) + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + return rec +} + +// putPasskey stores a passkey credential for an identity (the WebAuthn fake). +func (h *harness) putPasskey(t *testing.T, identityID int64) { + t.Helper() + err := h.ids.PutWebAuthnCredential(h.ctx, identityID, webauthn.Credential{ + ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"), SignCount: 1, + }) + if err != nil { + t.Fatalf("PutWebAuthnCredential: %v", err) + } +} + +// countAudit returns how many audit rows carry the given action. +func (h *harness) countAudit(t *testing.T, action string) int { + t.Helper() + var n int + if err := h.db.QueryRow(`SELECT count(*) FROM audit_log WHERE action = ?`, action).Scan(&n); err != nil { + t.Fatalf("count audit %q: %v", action, err) + } + return n +} + +func (h *harness) sessionCookie(id string) *http.Cookie { + return &http.Cookie{Name: httpx.SessionCookieName, Value: id} +} + +// ── AC-8 ───────────────────────────────────────────────────────────────────── + +func TestSpec006AC8_TOTPLoginWithoutPasskeyIsNudged(t *testing.T) { + h := newHarness(t) + code := h.issueTOTP(t, h.session.IdentityID) + + rec := h.totpLogin(t, h.guid, code, "") // httptest Host = example.com (public) + if rec.Code != http.StatusSeeOther { + t.Fatalf("TOTP login = %d, want 303 (AC-8)", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" { + t.Errorf("TOTP login without a passkey landed on %q, want the nudge (AC-8)", loc) + } + if s := sessionCookie(t, rec); s == "" { + t.Error("the nudge redirect carried no session cookie (AC-8)") + } + + // A TOTP login WITH a passkey present is NEVER nudged (AC-8, second half). + h.putPasskey(t, h.session.IdentityID) + code = h.issueTOTP(t, h.session.IdentityID) + rec = h.totpLogin(t, h.guid, code, "") + if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/home" { + t.Errorf("TOTP login with a passkey landed on %q, want the app (AC-8)", loc) + } +} + +// ── AC-9 ───────────────────────────────────────────────────────────────────── + +func TestSpec006AC9_DontAskAgainPersistsAndSilences(t *testing.T) { + h := newHarness(t) + code := h.issueTOTP(t, h.session.IdentityID) + + // First TOTP login -> nudge. + rec := h.totpLogin(t, h.guid, code, "") + if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" { + t.Fatalf("first TOTP login = %q, want the nudge (AC-9)", loc) + } + sid := sessionCookie(t, rec) + + // "Don't ask again" -> passkey_opt_out = true (audited). + never := h.sessionAction("passkey.never", h.guid, sid) + if never.Code != http.StatusSeeOther { + t.Fatalf("passkey.never = %d, want 303 (AC-9)", never.Code) + } + if loc := never.Header().Get("Location"); loc != "/"+h.guid+"/home" { + t.Errorf("passkey.never landed on %q, want the app (AC-9)", loc) + } + optOut, err := h.users.PasskeyOptOut(h.ctx, h.adminID) + if err != nil { + t.Fatalf("PasskeyOptOut: %v", err) + } + if !optOut { + t.Error("passkey.never did not persist the opt-out (AC-9)") + } + if n := h.countAudit(t, "user.passkey_opt_out"); n != 1 { + t.Errorf("opt-out audit rows = %d, want 1 (A11/AC-9)", n) + } + + // Later TOTP logins are never nudged again. + code = h.issueTOTP(t, h.session.IdentityID) + rec = h.totpLogin(t, h.guid, code, "") + if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/home" { + t.Errorf("a login after opting out landed on %q, want the app (AC-9)", loc) + } +} + +// ── AC-10 ──────────────────────────────────────────────────────────────────── + +func TestSpec006AC10_NoNudgeOnLoopback(t *testing.T) { + h := newHarness(t) + code := h.issueTOTP(t, h.session.IdentityID) + + rec := h.totpLogin(t, h.guid, code, "127.0.0.1") // the loopback origin + if rec.Code != http.StatusSeeOther { + t.Fatalf("loopback TOTP login = %d, want 303 (AC-10)", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/home" { + t.Errorf("loopback TOTP login landed on %q, want the app — no nudge (AC-10)", loc) + } +} + +func TestSpec006AC10_NoNudgeInSetupMode(t *testing.T) { + h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } }) + + // In setup mode the app serves ONLY /setup: the TOTP login action and the + // passkey screen are both unreachable, so the nudge can never appear. + if rec := h.totpLogin(t, h.guid, "123456", ""); rec.Code != http.StatusNotFound { + t.Errorf("TOTP login in setup mode = %d, want 404 (AC-10)", rec.Code) + } + if rec := request(h.srv, "GET", "/"+h.guid+"/auth/passkey", h.sessionCookie(h.session.ID)); rec.Code != http.StatusNotFound { + t.Errorf("the passkey screen in setup mode = %d, want 404 (AC-10)", rec.Code) + } +} + +// ── AC-11 ──────────────────────────────────────────────────────────────────── + +func TestSpec006AC11_PasskeyAddRegistersAndAudits(t *testing.T) { + h := newHarness(t) + code := h.issueTOTP(t, h.session.IdentityID) + + // TOTP login -> the nudge screen. + rec := h.totpLogin(t, h.guid, code, "") + if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" { + t.Fatalf("TOTP login = %q, want the nudge (AC-11)", loc) + } + sid := sessionCookie(t, rec) + + // GET the nudge screen with the session: it begins the registration and + // carries the ceremony state. + page := request(h.srv, "GET", "/"+h.guid+"/auth/passkey", h.sessionCookie(sid)) + if page.Code != http.StatusOK { + t.Fatalf("GET the passkey screen = %d, want 200 (AC-11)", page.Code) + } + if !strings.Contains(page.Body.String(), "passkey.add") { + t.Error("the nudge screen does not offer passkey.add (AC-11)") + } + if h.wa.beginReg != 1 { + t.Errorf("BeginRegistration calls = %d, want 1 (AC-11)", h.wa.beginReg) + } + + // passkey.add completes the registration: the credential is stored and the + // event is audited. + add := h.sessionAction("passkey.add", h.guid, sid, ceremonyCookie(t, page)) + if add.Code != http.StatusSeeOther { + t.Fatalf("passkey.add = %d, want 303 (AC-11)", add.Code) + } + if loc := add.Header().Get("Location"); loc != "/"+h.guid+"/home" { + t.Errorf("passkey.add landed on %q, want the app (AC-11)", loc) + } + has, err := h.ids.HasWebAuthnCredential(h.ctx, h.session.IdentityID) + if err != nil || !has { + t.Errorf("passkey.add stored no credential (AC-11): %v", err) + } + if n := h.countAudit(t, "passkey.add"); n != 1 { + t.Errorf("passkey.add audit rows = %d, want 1 (AC-11)", n) + } +} + +// TestSpec006_NudgeLaterPersistsNothing: "Not now" skips this time — nothing is +// written, and the user is nudged again at the next TOTP login (SPEC-006 §8). +func TestSpec006_NudgeLaterPersistsNothing(t *testing.T) { + h := newHarness(t) + code := h.issueTOTP(t, h.session.IdentityID) + + rec := h.totpLogin(t, h.guid, code, "") + sid := sessionCookie(t, rec) + + later := h.sessionAction("passkey.later", h.guid, sid) + if later.Code != http.StatusSeeOther { + t.Fatalf("passkey.later = %d, want 303", later.Code) + } + if optOut, err := h.users.PasskeyOptOut(h.ctx, h.adminID); err != nil || optOut { + t.Errorf("passkey.later persisted an opt-out (SPEC-006 §8): %v", err) + } + + // Still nudged next time. + code = h.issueTOTP(t, h.session.IdentityID) + rec = h.totpLogin(t, h.guid, code, "") + if loc := rec.Header().Get("Location"); loc != "/"+h.guid+"/auth/passkey" { + t.Errorf("after 'not now' the next login landed on %q, want the nudge", loc) + } +} + +// TestSpec006_PasskeyLoginNeverNudged: a passkey login never nudges, even with +// a passkey present and a public origin (SPEC-006 §8). +func TestSpec006_PasskeyLoginNeverNudged(t *testing.T) { + h := newHarness(t) + h.putPasskey(t, h.session.IdentityID) + + begin := h.action("login.begin", h.guid, nil) + if begin.Code != http.StatusOK { + t.Fatalf("login.begin = %d, want 200", begin.Code) + } + finish := h.action("login.finish", h.guid, nil, ceremonyCookie(t, begin)) + if finish.Code != http.StatusSeeOther { + t.Fatalf("login.finish = %d, want 303", finish.Code) + } + if loc := finish.Header().Get("Location"); loc != "/"+h.guid+"/home" { + t.Errorf("a passkey login landed on %q, want the app — never nudged", loc) + } + if h.wa.finishLogin != 1 { + t.Errorf("FinishLogin calls = %d, want 1", h.wa.finishLogin) + } +} diff --git a/internal/core/server/spec007_recovery_test.go b/internal/core/server/spec007_recovery_test.go new file mode 100644 index 0000000..b2440f6 --- /dev/null +++ b/internal/core/server/spec007_recovery_test.go @@ -0,0 +1,524 @@ +package server_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +// member is one enrolled user with a live session (the chain's four tiers). +type member struct { + userID int64 + identityID int64 + guid string + sessionID string + email string + role users.Role +} + +type recoveryHarness struct { + t *testing.T + ctx context.Context + db *store.DB + users *users.Store + ids *auth.IdentitiesService + sessions *auth.SessionsService + requests *recovery.Store + mailer *mail.Fake + csrf *httpx.CSRF + pre *httpx.PreSessionCSRF + srv *server.Server + + admin member + director member + manager member + team member + peer member // a second team member (same role as team) +} + +func newRecoveryHarness(t *testing.T) *recoveryHarness { + t.Helper() + ctx := context.Background() + + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + aud := audit.New(db.DB) + us := users.New(db.DB, aud) + + sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x51}, auth.KeySize)) + if err != nil { + t.Fatalf("NewAEADSealer: %v", err) + } + core := auth.New(db.DB, aud, auth.WithSealer(sealer)) + ids := core.Identities() + sessions := core.Sessions() + + const ( + csrfKey = "recovery-csrf-key-0123456789abcdef" + cookieSecret = "recovery-cookie-secret-0123456789abcdef" + ) + csrf, err := httpx.NewCSRF([]byte(csrfKey)) + if err != nil { + t.Fatalf("NewCSRF: %v", err) + } + pre, err := httpx.NewPreSessionCSRF([]byte(cookieSecret), false) + if err != nil { + t.Fatalf("NewPreSessionCSRF: %v", err) + } + + h := &recoveryHarness{ + t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions, + mailer: mail.NewFake(), csrf: csrf, pre: pre, + } + h.requests = recovery.NewStore(db.DB, aud) + + // The chain, built top-down (D-019). + h.admin = h.enroll(ctx, 0, "admin@example.com", "Admin", users.RoleAdmin) + h.director = h.enroll(ctx, h.admin.userID, "director@example.com", "Director", users.RoleDirector) + h.manager = h.enroll(ctx, h.director.userID, "manager@example.com", "Manager", users.RoleManager) + h.team = h.enroll(ctx, h.manager.userID, "team@example.com", "Team", users.RoleTeam) + h.peer = h.enroll(ctx, h.manager.userID, "peer@example.com", "Peer", users.RoleTeam) + + rec, err := recovery.New(recovery.Deps{ + Requests: h.requests, + Users: us, + Identities: ids, + Mailer: h.mailer, + CSRF: pre, + Audit: aud, + BaseURL: "https://myday.example.com", + Logger: nil, + }) + if err != nil { + t.Fatalf("recovery.New: %v", err) + } + + reg := server.NewMapRegistry() + reg.RegisterCore("home", server.Home) + reg.RegisterModule(server.NewModule("home"). + SetDefault("dashboard", server.Home). + AddRoute("home", "home", server.Home)) + server.RegisterRecoveryFlow(reg, rec) + + cfg := &config.Config{ + Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "https://myday.example.com"}, + Database: config.Database{Path: db.Path()}, + Secrets: config.Secrets{CSRFKey: csrfKey, CookieSecret: cookieSecret}, + } + srv, err := server.New(server.Deps{ + Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions, + Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre, + RoleGate: server.NewTiers(), + }) + if err != nil { + t.Fatalf("server.New: %v", err) + } + h.srv = srv + return h +} + +// enroll creates a user on the chain, activates an identity and mints a session. +func (h *recoveryHarness) enroll(ctx context.Context, creator int64, email, name string, role users.Role) member { + h.t.Helper() + uid, err := h.users.Create(ctx, creator, email, name, role) + if err != nil { + h.t.Fatalf("create %s: %v", role, err) + } + id, guid, err := h.ids.Mint(ctx, uid) + if err != nil { + h.t.Fatalf("Mint %s: %v", role, err) + } + if err := h.ids.Activate(ctx, id); err != nil { + h.t.Fatalf("Activate %s: %v", role, err) + } + sess, err := h.sessions.Mint(ctx, id, "127.0.0.1", "test") + if err != nil { + h.t.Fatalf("session Mint %s: %v", role, err) + } + return member{userID: uid, identityID: id, guid: guid, sessionID: sess.ID, email: email, role: role} +} + +func (h *recoveryHarness) preCSRF() (*http.Cookie, string) { + h.t.Helper() + tok, err := h.pre.Token() + if err != nil { + h.t.Fatalf("pre-session Token: %v", err) + } + return &http.Cookie{Name: httpx.PreSessionCookieName, Value: tok}, tok +} + +// access posts the recovery request form (anonymous, A15). +func (h *recoveryHarness) access(email string) *httptest.ResponseRecorder { + h.t.Helper() + cookie, token := h.preCSRF() + form := url.Values{"email": {email}, "csrf": {token}} + req := httptest.NewRequest("POST", "/access?do=auth&action=recover", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + return rec +} + +// decide issues a session-gated approve/deny action as actor. +func (h *recoveryHarness) decide(action string, actor member, requestID int64) *httptest.ResponseRecorder { + h.t.Helper() + form := url.Values{ + "request": {strconv.FormatInt(requestID, 10)}, + "csrf": {h.csrf.Token(actor.sessionID)}, + } + req := httptest.NewRequest("POST", "/"+actor.guid+"?do=recovery&action="+action, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: httpx.SessionCookieName, Value: actor.sessionID}) + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + return rec +} + +// openRequest submits team's recovery request and returns its id. +func (h *recoveryHarness) openRequest(m member) recovery.Request { + h.t.Helper() + if rec := h.access(m.email); rec.Code != http.StatusOK { + h.t.Fatalf("POST /access = %d, want 200", rec.Code) + } + req, err := h.requests.Pending(h.ctx, m.userID) + if err != nil { + h.t.Fatalf("Pending(%d): %v", m.userID, err) + } + return req +} + +// auditActions returns the set of audit actions recorded. +func (h *recoveryHarness) auditActions(t *testing.T) map[string]bool { + t.Helper() + rows, err := h.db.Query(`SELECT action FROM audit_log`) + if err != nil { + t.Fatalf("read audit_log: %v", err) + } + defer rows.Close() + got := map[string]bool{} + for rows.Next() { + var a string + if err := rows.Scan(&a); err != nil { + t.Fatalf("scan audit action: %v", err) + } + got[a] = true + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate audit_log: %v", err) + } + return got +} + +// ── AC-2 ───────────────────────────────────────────────────────────────────── + +// TestSpec007AC2_NoSelfApprovalOrLowerRole: the requester can never approve +// their own request, and a peer (same role) cannot either (D-019, AC-2). +func TestSpec007AC2_NoSelfApprovalOrLowerRole(t *testing.T) { + h := newRecoveryHarness(t) + req := h.openRequest(h.team) + + // The requester themselves -> forbidden. The body must be EXACTLY the + // client error — a returned error would make the door append a 500 on top + // of an already-written response. + if rec := h.decide("approve", h.team, req.ID); rec.Code != http.StatusForbidden { + t.Errorf("self-approval = %d, want 403 (AC-2)", rec.Code) + } else if got := rec.Body.String(); strings.TrimSpace(got) != "forbidden" { + t.Errorf("self-approval body = %q, want exactly %q", got, "forbidden") + } + // A peer (same role) -> forbidden. + if rec := h.decide("approve", h.peer, req.ID); rec.Code != http.StatusForbidden { + t.Errorf("peer approval = %d, want 403 (AC-2)", rec.Code) + } + + // Nothing changed: the identity is still active, the request still pending. + idn, err := h.ids.ByGUID(h.ctx, h.team.guid) + if err != nil || idn.Status != auth.StatusActive { + t.Errorf("identity after a refused approval = %+v, want still active (AC-2)", idn) + } + still, err := h.requests.Get(h.ctx, req.ID) + if err != nil || still.Status != recovery.StatusPending { + t.Errorf("request after a refused approval = %+v, want pending (AC-2)", still) + } +} + +// TestSpec007AC2_R7_ChainAuthority: the recovery authority is the CHAIN (R-7). +// A director in a DIFFERENT subtree is a role above a team member but not on +// their manager line, so they may NOT approve; the admin (the root) may. +func TestSpec007AC2_R7_ChainAuthority(t *testing.T) { + h := newRecoveryHarness(t) + + // A second director, created by the admin: role-above team, but NOT an + // ancestor of team (team -> manager -> director -> admin). + directorB := h.enroll(h.ctx, h.admin.userID, "director2@example.com", "Director2", users.RoleDirector) + + req := h.openRequest(h.team) + + // Role-above but off the chain -> forbidden (R-7's correction). + if rec := h.decide("approve", directorB, req.ID); rec.Code != http.StatusForbidden { + t.Errorf("non-ancestor director approval = %d, want 403 (R-7)", rec.Code) + } + // The request is untouched by the refusal. + if still, err := h.requests.Get(h.ctx, req.ID); err != nil || still.Status != recovery.StatusPending { + t.Errorf("request after a refused off-chain approval = %+v, want pending (R-7)", still) + } + + // The admin is the root of the chain and may approve. + if rec := h.decide("approve", h.admin, req.ID); rec.Code != http.StatusOK { + t.Errorf("admin approval = %d, want 200 (R-7)", rec.Code) + } +} + +// ── AC-3 ───────────────────────────────────────────────────────────────────── + +// TestSpec007AC3_ApprovalRotatesIdentity: an approver on the requester's chain +// revokes the old identity and all its sessions and mints a NEW GUID, then +// emails the enrolment link (D-007, AC-3). +func TestSpec007AC3_ApprovalRotatesIdentity(t *testing.T) { + h := newRecoveryHarness(t) + req := h.openRequest(h.team) + + rec := h.decide("approve", h.manager, req.ID) // manager is above team + if rec.Code != http.StatusOK { + t.Fatalf("approve = %d, want 200 (body %q)", rec.Code, rec.Body.String()) + } + + // The OLD identity is revoked and its GUID tombstoned. + old, err := h.ids.ByGUID(h.ctx, h.team.guid) + if err != nil { + t.Fatalf("ByGUID(old): %v", err) + } + if old.Status != auth.StatusRevoked { + t.Errorf("old identity status = %s, want revoked (AC-3)", old.Status) + } + // Every session of the old identity is dead. + if _, err := h.sessions.Validate(h.ctx, h.team.sessionID); err == nil { + t.Error("a pre-approval session survived the rotation (AC-3)") + } + + // A NEW GUID is minted, pending enrollment, under the same user. + fresh, err := h.ids.ByUserID(h.ctx, h.team.userID) + if err != nil { + t.Fatalf("ByUserID: %v", err) + } + if fresh.GUID == h.team.guid { + t.Error("approval reissued the old GUID (AC-3)") + } + if fresh.Status != auth.StatusPendingEnrollment { + t.Errorf("new identity status = %s, want pending_enrollment (AC-3)", fresh.Status) + } + + // The enrolment link reached the address on record. + found := false + for _, m := range h.mailer.To(h.team.email) { + if strings.Contains(m.Body, fresh.GUID) { + found = true + } + } + if !found { + t.Errorf("no enrolment email carrying the new GUID reached %s (AC-3)", h.team.email) + } + + // The request is approved. + done, err := h.requests.Get(h.ctx, req.ID) + if err != nil || done.Status != recovery.StatusApproved { + t.Errorf("request = %+v, want approved (AC-3)", done) + } +} + +// ── AC-4 ───────────────────────────────────────────────────────────────────── + +// TestSpec007AC4_AccessIsGeneric: /access never reveals whether the email +// matched — the same status and the same body either way (A3, AC-4). +func TestSpec007AC4_AccessIsGeneric(t *testing.T) { + h := newRecoveryHarness(t) + + hit := h.access(h.team.email) + miss := h.access("nobody@example.com") + + if hit.Code != http.StatusOK || miss.Code != http.StatusOK { + t.Fatalf("handle codes = %d/%d, want 200/200", hit.Code, miss.Code) + } + if hit.Body.String() != miss.Body.String() { + t.Errorf("the /access response differs on a hit vs a miss:\nhit: %q\nmiss: %q", + hit.Body.String(), miss.Body.String()) + } + if !strings.Contains(hit.Body.String(), "If a record exists") { + t.Errorf("the /access notice is not generic: %q", hit.Body.String()) + } +} + +// ── AC-6 ───────────────────────────────────────────────────────────────────── + +// TestSpec007AC6_RecoveryIsAudited: request, approve, deny and the identity +// rotation are all recorded (A11, AC-6). +func TestSpec007AC6_RecoveryIsAudited(t *testing.T) { + h := newRecoveryHarness(t) + + // approve + req := h.openRequest(h.team) + if rec := h.decide("approve", h.manager, req.ID); rec.Code != http.StatusOK { + t.Fatalf("approve = %d, want 200", rec.Code) + } + // deny + req2 := h.openRequest(h.manager) + if rec := h.decide("deny", h.director, req2.ID); rec.Code != http.StatusOK { + t.Fatalf("deny = %d, want 200", rec.Code) + } + + got := h.auditActions(t) + for _, want := range []string{ + "recovery.request", + "recovery.approve", + "recovery.deny", + "identity.rotate", + } { + if !got[want] { + t.Errorf("audit log is missing %q (AC-6); got %v", want, got) + } + } +} + +// ── supporting behaviour ───────────────────────────────────────────────────── + +// TestSpec007AccessIssuesPreSessionCSRF: the anonymous /access form carries the +// signed pre-session token (A15) and the action without it is refused. +func TestSpec007AccessIssuesPreSessionCSRF(t *testing.T) { + h := newRecoveryHarness(t) + + rec := request(h.srv, "GET", "/access") + if rec.Code != http.StatusOK { + t.Fatalf("GET /access = %d, want 200", rec.Code) + } + if !hasCookie(rec, httpx.PreSessionCookieName) { + t.Error("GET /access issued no pre-session CSRF cookie (A15)") + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("GET /access Cache-Control = %q, want no-store", got) + } + + // Without the double-submit token the action is refused (A15). + req := httptest.NewRequest("POST", "/access?do=auth&action=recover", + strings.NewReader(url.Values{"email": {h.team.email}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + h.srv.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("recovery request without CSRF = %d, want 403 (A15)", rr.Code) + } +} + +// TestSpec007_RepeatRequestDoesNotRenotify: a second POST for the same user +// reuses the pending request and does NOT email the chain again (no spam). +func TestSpec007_RepeatRequestDoesNotRenotify(t *testing.T) { + h := newRecoveryHarness(t) + + if rec := h.access(h.team.email); rec.Code != http.StatusOK { + t.Fatalf("first POST /access = %d, want 200", rec.Code) + } + first := len(h.mailer.Sent()) + if first == 0 { + t.Fatal("the first request sent no notification") + } + + if rec := h.access(h.team.email); rec.Code != http.StatusOK { + t.Fatalf("second POST /access = %d, want 200", rec.Code) + } + if got := len(h.mailer.Sent()); got != first { + t.Errorf("a repeated request re-notified the chain: %d messages, want %d", got, first) + } +} + +// TestSpec007_SecondDecisionRefused: the request is claimed atomically — a +// second approve/deny is refused and does not rotate a second time. +func TestSpec007_SecondDecisionRefused(t *testing.T) { + h := newRecoveryHarness(t) + req := h.openRequest(h.team) + + if rec := h.decide("approve", h.manager, req.ID); rec.Code != http.StatusOK { + t.Fatalf("first approve = %d, want 200 (body %q)", rec.Code, rec.Body.String()) + } + if rec := h.decide("approve", h.director, req.ID); rec.Code != http.StatusConflict { + t.Errorf("second approve = %d, want 409 (already decided)", rec.Code) + } + + // The user has exactly one live (pending) identity — no double rotation. + var n int + if err := h.db.QueryRow( + `SELECT count(*) FROM identities WHERE user_id = ? AND status <> 'revoked'`, h.team.userID, + ).Scan(&n); err != nil { + t.Fatalf("count live identities: %v", err) + } + if n != 1 { + t.Errorf("live identities = %d, want exactly 1 (no double rotation)", n) + } +} + +// TestSpec007_ExpiredRequestCannotBeApproved: a request past the ~72h window +// is expired and refused (SPEC-007 §4). +func TestSpec007_ExpiredRequestCannotBeApproved(t *testing.T) { + h := newRecoveryHarness(t) + req := h.openRequest(h.team) + + // Age the request well past the TTL. + past := store.FormatTime(time.Now().Add(-100 * time.Hour)) + if _, err := h.db.Exec(`UPDATE recovery_requests SET requested_at = ? WHERE request_id = ?`, past, req.ID); err != nil { + t.Fatalf("age request: %v", err) + } + + rec := h.decide("approve", h.manager, req.ID) + if rec.Code != http.StatusGone { + t.Errorf("approve an expired request = %d, want 410", rec.Code) + } + // The identity was NOT rotated. + idn, err := h.ids.ByGUID(h.ctx, h.team.guid) + if err != nil || idn.Status != auth.StatusActive { + t.Errorf("identity after an expired approval = %+v, want still active", idn) + } + stored, err := h.requests.Get(h.ctx, req.ID) + if err != nil || stored.Status != recovery.StatusExpired { + t.Errorf("request = %+v, want expired", stored) + } +} + +// TestSpec007DenyDoesNotRotate: a denial leaves the requester's identity intact. +func TestSpec007DenyDoesNotRotate(t *testing.T) { + h := newRecoveryHarness(t) + req := h.openRequest(h.team) + + if rec := h.decide("deny", h.director, req.ID); rec.Code != http.StatusOK { + t.Fatalf("deny = %d, want 200", rec.Code) + } + idn, err := h.ids.ByGUID(h.ctx, h.team.guid) + if err != nil || idn.Status != auth.StatusActive { + t.Errorf("identity after a denial = %+v, want still active", idn) + } + done, err := h.requests.Get(h.ctx, req.ID) + if err != nil || done.Status != recovery.StatusDenied { + t.Errorf("request = %+v, want denied", done) + } +} diff --git a/internal/core/server/spec008_modules_test.go b/internal/core/server/spec008_modules_test.go new file mode 100644 index 0000000..b89177e --- /dev/null +++ b/internal/core/server/spec008_modules_test.go @@ -0,0 +1,235 @@ +package server_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync" + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" + + // Discovery (D-028): blank-importing the aggregator runs the module + // directories' init(), so module.Load sees the real, discovered set. + _ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/modules" +) + +// doorAudit records audit action names in memory (AC-6). +type doorAudit struct { + mu sync.Mutex + actions []string +} + +func (a *doorAudit) Record(_ context.Context, e audit.Event) { + a.mu.Lock() + defer a.mu.Unlock() + a.actions = append(a.actions, e.Action) +} + +func (a *doorAudit) has(action string) bool { + a.mu.Lock() + defer a.mu.Unlock() + for _, x := range a.actions { + if x == action { + return true + } + } + return false +} + +// testModule is a minimal OPTIONAL module (a stand-in for a future feature such +// as `expense`) used to exercise enable/disable through the door. +type testModule struct{ name string } + +func (m *testModule) Name() string { return m.name } +func (m *testModule) Required() bool { return false } +func (m *testModule) Kind() string { return "domain" } +func (m *testModule) Migrations() []module.Migration { + return nil +} +func (m *testModule) Routes() []module.Route { + return []module.Route{{Function: "index", Handler: func(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error { + w.WriteHeader(http.StatusOK) + return nil + }, Default: true}} +} +func (m *testModule) Actions() []module.Action { return nil } +func (m *testModule) Register(r *module.Registry) error { return r.Add(m) } +func (m *testModule) Start(context.Context) error { return nil } + +// consoleGate is the admin-only role gate the entrypoint installs (SPEC-008 §7). +func consoleGate() *server.Tiers { + return server.NewTiers(). + Require(module.ConsoleModule, module.FunctionModules, users.RoleAdmin). + Require(module.ConsoleModule, module.FunctionToggle, users.RoleAdmin) +} + +// buildConsole mounts a Modules page (with a writable temp config) onto reg. +func buildConsole(t *testing.T, mods *module.Registry, reg *server.MapRegistry, aud audit.Audit) *config.Config { + t.Helper() + cfg := &config.Config{ + Server: config.Server{Listen: "127.0.0.1:8080"}, + Database: config.Database{Path: "test.db"}, + Modules: map[string]bool{}, + } + page, err := module.NewPage(module.PageDeps{ + Registry: mods, + Config: cfg, + ConfigPath: filepath.Join(t.TempDir(), config.FileName), + Audit: aud, + }) + if err != nil { + t.Fatalf("module.NewPage: %v", err) + } + page.Register(reg) + return cfg +} + +// serveForm issues a urlencoded POST against the door. +func serveForm(h http.Handler, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", target, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +// TestSpec008_DoorDispatchesToDiscoveredHome is the definition-of-done proof: +// the discovered `home` module mounts into the door and the door dispatches +// /{GUID} to its dashboard. +func TestSpec008_DoorDispatchesToDiscoveredHome(t *testing.T) { + mods, err := module.Load(nil) + if err != nil { + t.Fatalf("module.Load: %v", err) + } + + reg := server.NewMapRegistry() + mods.Mount(reg) + + h := newHarness(t, func(d *server.Deps) { d.Registry = reg }) + + rec := request(h.srv, "GET", "/"+h.guid, h.cookie()) + if rec.Code != http.StatusOK { + t.Fatalf("GET /{guid} = %d, want 200 (body %q)", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Your day, at a glance.") { + t.Errorf("the door did not dispatch to the discovered home module:\n%s", rec.Body.String()) + } + + // /{GUID}/home reaches the same module default (SPEC-002 §4.4). + rec = request(h.srv, "GET", "/"+h.guid+"/home", h.cookie()) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Your day, at a glance.") { + t.Errorf("GET /{guid}/home did not reach the home module: %d %q", rec.Code, rec.Body.String()) + } +} + +// TestSpec008_ModulesPageIsAdminOnly: the core Modules page and its toggle are +// gated to the admin by the door's role gate (SPEC-008 §7, D-038/AC-4). +func TestSpec008_ModulesPageIsAdminOnly(t *testing.T) { + mods, err := module.Load(nil) + if err != nil { + t.Fatalf("module.Load: %v", err) + } + reg := server.NewMapRegistry() + mods.Mount(reg) + pageCfg := buildConsole(t, mods, reg, nil) + + h := newHarness(t, func(d *server.Deps) { + d.Registry = reg + d.RoleGate = consoleGate() + }) + + // The admin may open it. + rec := request(h.srv, "GET", "/"+h.guid+"/core/modules", h.cookie()) + if rec.Code != http.StatusOK { + t.Fatalf("admin GET /{guid}/core/modules = %d, want 200 (body %q)", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "home") { + t.Errorf("the Modules page does not list the discovered modules:\n%s", rec.Body.String()) + } + + // A team member may not (403). + teamID, err := h.users.Create(h.ctx, h.adminID, "worker@example.com", "Worker", users.RoleTeam) + if err != nil { + t.Fatalf("create team: %v", err) + } + idTeam, guidTeam, err := h.ids.Mint(h.ctx, teamID) + if err != nil { + t.Fatalf("Mint team: %v", err) + } + if err := h.ids.Activate(h.ctx, idTeam); err != nil { + t.Fatalf("Activate team: %v", err) + } + sess, err := h.sessions.Mint(h.ctx, idTeam, "127.0.0.1", "test") + if err != nil { + t.Fatalf("session Mint team: %v", err) + } + + rec = request(h.srv, "GET", "/"+guidTeam+"/core/modules", + &http.Cookie{Name: httpx.SessionCookieName, Value: sess.ID}) + if rec.Code != http.StatusForbidden { + t.Errorf("team GET /{guid}/core/modules = %d, want 403 (admin only)", rec.Code) + } + + // A required module cannot be disabled, even by the admin (AC-4). + form := url.Values{ + "module": {"home"}, + "enabled": {"false"}, + "csrf": {h.csrf.Token(h.session.ID)}, + } + if rec := serveForm(h.srv, "/"+h.guid+"?do=core&action=modules.toggle", form, h.cookie()); rec.Code != http.StatusForbidden { + t.Errorf("admin disabling a required module = %d, want 403 (AC-4)", rec.Code) + } + if pageCfg.Modules["home"] { + t.Error("a required module was written as disabled (AC-4)") + } +} + +// TestSpec008_ModulesPageToggleAudited: an admin toggling an optional module +// writes the config and is audited (D-035, AC-6). +func TestSpec008_ModulesPageToggleAudited(t *testing.T) { + mods, err := module.Load(nil) + if err != nil { + t.Fatalf("module.Load: %v", err) + } + if err := mods.Add(&testModule{name: "expense"}); err != nil { + t.Fatalf("add expense: %v", err) + } + reg := server.NewMapRegistry() + mods.Mount(reg) + + rec := &doorAudit{} + pageCfg := buildConsole(t, mods, reg, rec) + + h := newHarness(t, func(d *server.Deps) { + d.Registry = reg + d.RoleGate = consoleGate() + }) + + form := url.Values{ + "module": {"expense"}, + "enabled": {"false"}, + "csrf": {h.csrf.Token(h.session.ID)}, + } + res := serveForm(h.srv, "/"+h.guid+"?do=core&action=modules.toggle", form, h.cookie()) + if res.Code != http.StatusOK { + t.Fatalf("toggle = %d, want 200 (body %q)", res.Code, res.Body.String()) + } + if pageCfg.Modules["expense"] { + t.Error("the config was not updated for the optional module") + } + if !rec.has("module.disable") { + t.Errorf("the toggle was not audited (AC-6): %v", rec.actions) + } +} diff --git a/internal/core/server/spec009_web_pwa_test.go b/internal/core/server/spec009_web_pwa_test.go new file mode 100644 index 0000000..daaf988 --- /dev/null +++ b/internal/core/server/spec009_web_pwa_test.go @@ -0,0 +1,475 @@ +package server_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" + + _ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/modules" +) + +// ── SPEC-009 · the web layer & PWA ─────────────────────────────────────────── + +// staticRegistry is the door registry as the entrypoint wires it for the +// reserved workflows (SPEC-009 §3): `home` plus the embedded `static` assets. +func staticRegistry() *server.MapRegistry { + reg := server.NewMapRegistry() + reg.RegisterCore("home", server.Home) + reg.RegisterCore(server.StaticWorkflow, server.StaticHandler()) + return reg +} + +// ── AC-3: no inline scripts anywhere ───────────────────────────────────────── + +var ( + htmlScriptTag = regexp.MustCompile(`(?is)]*>`) + htmlScriptSrc = regexp.MustCompile(`(?is)]*\bsrc\s*=\s*["'][^"']*["']`) + htmlInlineHandler = regexp.MustCompile(`(?is)\son(click|change|input|submit|load|error)\s*=`) + htmlJavascriptURL = regexp.MustCompile(`(?is)javascript:`) +) + +// assertNoInlineScripts fails when a rendered page would force the CSP +// `script-src 'self'` (SPEC-003 §4) to be weakened: an inline