The shell now renders the CORE nav entries — Home · Account for everyone, Users · Modules only for an admin — alongside the registry entries, with the discovered `home` dashboard collapsed onto the core Home entry (SPEC-010 §3). A signed-in user with no passkey, who has not opted out and is not on a loopback origin, now sees a prominent "Add a passkey" banner linking to the Account page (SPEC-010 §6). This closes the D-041 gap where the wizard's direct session mint bypasses the TOTP-login nudge, so the admin was never prompted. The door computes (admin, banner) in serveGUID — it already holds the session, identity and user — and attaches it to the request context; the new web.PageFor copies it onto every authenticated page (RequestContext is untouched, so identification stays separate from authorisation, D-003).
763 lines
27 KiB
Go
763 lines
27 KiB
Go
// 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 (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"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"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
|
|
)
|
|
|
|
// 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
|
|
// Credentials answers "does this identity have a passkey?" — the door's
|
|
// post-setup passkey banner (SPEC-010 §6). When nil the banner never
|
|
// shows (it is only meaningful once a passkey store is wired).
|
|
Credentials auth.Credentials
|
|
// 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 WIZARD is enabled — the config toggle
|
|
// `setup` (D-045). When nil it defaults to Config.Setup; the entrypoint
|
|
// also clears it in-process once the wizard completes.
|
|
SetupMode func() bool
|
|
// LockedMode reports whether the instance is LOCKED: `setup: false` and no
|
|
// admin exists yet (D-045). In that state `GET /` and `GET /setup` render
|
|
// the "enable setup" boilerplate and every other route is 404 — the state
|
|
// is deliberately not publicly claimable. When nil it is never locked.
|
|
LockedMode func() bool
|
|
// SetupGate is the first-run gate: the OPTIONAL setup code + the signed
|
|
// unlock cookie, and the first-run pages (D-044/D-045). When nil, setup
|
|
// mode keeps the strict behaviour (only `/setup` served, everything else
|
|
// 404) — the entrypoint always wires it.
|
|
SetupGate SetupGate
|
|
// 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
|
|
creds auth.Credentials
|
|
users users.Users
|
|
audit audit.Audit
|
|
csrf *httpx.CSRF
|
|
precsrf *httpx.PreSessionCSRF
|
|
trusted httpx.TrustedProxies
|
|
setup func() bool
|
|
locked func() bool
|
|
gate SetupGate
|
|
roles RoleGate
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
limiter *middleware.RateLimiter
|
|
}
|
|
|
|
// SetupGate is the first-run gate the door consults. It reports whether a
|
|
// required setup code has been unlocked and renders the first-run pages that
|
|
// replace a bare 404 (D-044/D-045). It is implemented by *setup.Gate;
|
|
// declaring the seam here keeps the door free of the setup package's internals.
|
|
type SetupGate interface {
|
|
// Required reports whether a setup code is required to reach the wizard
|
|
// (D-045: `setup_code` is set). When false the `setup` toggle alone admits.
|
|
Required() bool
|
|
// Unlocked reports whether the request carries a valid setup-code unlock
|
|
// (D-044).
|
|
Unlocked(r *http.Request) bool
|
|
// CodeForm renders the setup-code entry page (setup_code set, not yet
|
|
// unlocked).
|
|
CodeForm(w http.ResponseWriter, r *http.Request) error
|
|
// Landing renders the page `GET /` shows while setup is enabled.
|
|
Landing(w http.ResponseWriter, r *http.Request) error
|
|
// Locked renders the "enable setup and restart" page (setup: false, no
|
|
// admin).
|
|
Locked(w http.ResponseWriter, r *http.Request) error
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
locked := d.LockedMode
|
|
if locked == nil {
|
|
locked = func() bool { return false }
|
|
}
|
|
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,
|
|
creds: d.Credentials,
|
|
users: d.Users,
|
|
audit: d.Audit,
|
|
csrf: d.CSRF,
|
|
precsrf: d.PreSession,
|
|
trusted: trusted,
|
|
setup: setup,
|
|
locked: locked,
|
|
gate: d.SetupGate,
|
|
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. Any address may be
|
|
// bound — including `0.0.0.0:8080` behind a reverse proxy (D-043); the
|
|
// bootstrap is gated per request by the `setup` toggle (D-045), not by a bind
|
|
// check.
|
|
func (s *Server) Serve(listen string) error {
|
|
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)
|
|
|
|
// The scheme is resolved per request (D-043): TLS, or a trusted proxy's
|
|
// X-Forwarded-Proto. Every cookie writer reads it back off the context.
|
|
secure := httpx.RequestIsHTTPS(r, s.trusted)
|
|
ctx = httpx.WithSecure(ctx, secure)
|
|
|
|
// 3. security headers.
|
|
httpx.SetSecurityHeaders(w, 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 / D-045: the setup gate is the config toggle `setup`,
|
|
// evaluated per request.
|
|
//
|
|
// * wizard mode (`setup: true`) — `GET /setup` serves the wizard. If a
|
|
// `setup_code` is set it must be unlocked first (D-044); `GET /` points
|
|
// at the wizard; every other route is 404.
|
|
// * locked mode (`setup: false`, no admin) — `GET /` and `GET /setup`
|
|
// render the "enable setup" boilerplate; every other route is 404.
|
|
// Deliberately not claimable: the operator must flip the toggle.
|
|
if s.setup() {
|
|
admitted := s.setupAdmitted(r)
|
|
// Static assets are the first-run pages' stylesheet — served in every
|
|
// first-run state (SPEC-005 §3).
|
|
static := res.Kind == routing.KindRoute && res.Workflow == "static"
|
|
|
|
switch {
|
|
case res.Kind == routing.KindRedirect && res.Redirect == "/home":
|
|
// `GET /` — point at the wizard (never the app, D-045).
|
|
s.serveLanding(w, r, cid, ip)
|
|
return
|
|
case res.Kind == routing.KindSetup && !admitted:
|
|
// `GET /setup` with a required code not yet entered.
|
|
s.serveCodeForm(w, r, cid, ip)
|
|
return
|
|
case static:
|
|
// fall through — the wizard's stylesheet.
|
|
case !s.isSetupUnlock(res) && (!admitted || !setupAllowed(res)):
|
|
// SPEC-005 §3: once admitted, the app serves ONLY /setup; every
|
|
// other route is 404 (delete, don't redirect). The unlock action is
|
|
// the one exception — it is what grants the unlock (D-044).
|
|
s.log.Warn("setup request refused: the wizard is not admitted",
|
|
"correlation_id", cid, "client_ip", ip,
|
|
"path", httpx.RedactPath(r.URL.Path))
|
|
s.writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
} else if s.locked() {
|
|
// D-045: `setup: false` and no admin — a friendly, non-claimable
|
|
// boilerplate at `/` and `/setup`; 404 elsewhere (static assets are the
|
|
// one exception, so the boilerplate renders inside the shell).
|
|
switch {
|
|
case res.Kind == routing.KindRedirect && res.Redirect == "/home":
|
|
s.serveLocked(w, r, cid, ip)
|
|
return
|
|
case res.Kind == routing.KindSetup:
|
|
s.serveLocked(w, r, cid, ip)
|
|
return
|
|
case res.Kind == routing.KindRoute && res.Workflow == "static":
|
|
// fall through — the boilerplate's stylesheet (SPEC-005 §3).
|
|
default:
|
|
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
|
|
}
|
|
// M1c/L3s — pin the setup MODULE at the door: the wizard's functions are
|
|
// reachable ONLY through the reserved `setup` workflow. Any other
|
|
// resolution naming `do=setup` (a GUID path, a reserved workflow) is
|
|
// invalid in every mode and 404s rather than falling through to the
|
|
// module registry or the GUID door.
|
|
if res.Module == setupModule {
|
|
s.writeError(w, http.StatusNotFound, "not found")
|
|
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 the wizard itself — the
|
|
// reserved `setup` workflow, path or action (SPEC-005 §3). Used while the
|
|
// wizard is enabled: everything else is 404. Static assets are handled
|
|
// separately (they are served in every first-run state).
|
|
func setupAllowed(res routing.Resolution) bool {
|
|
switch res.Kind {
|
|
case routing.KindSetup:
|
|
return true
|
|
case routing.KindAction:
|
|
return res.Workflow == "setup"
|
|
}
|
|
return false
|
|
}
|
|
|
|
// setupAdmitted reports whether the request may reach the wizard while
|
|
// `setup: true` (D-045): always when no `setup_code` is required, otherwise
|
|
// only with a valid code unlock (D-044).
|
|
func (s *Server) setupAdmitted(r *http.Request) bool {
|
|
if s.gate == nil || !s.gate.Required() {
|
|
return true
|
|
}
|
|
return s.gate.Unlocked(r)
|
|
}
|
|
|
|
// isSetupUnlock reports whether res is the D-044 unlock action — the ONE request
|
|
// served while the wizard is enabled without an unlock, because it is what
|
|
// grants the unlock. The door still pins it to the `setup` module
|
|
// (serveSetupAction), so a `do=core` under /setup cannot ride this carve-out.
|
|
func (s *Server) isSetupUnlock(res routing.Resolution) bool {
|
|
return res.Kind == routing.KindAction &&
|
|
res.Workflow == "setup" &&
|
|
res.Module == setupModule &&
|
|
res.Function == "unlock"
|
|
}
|
|
|
|
// serveCodeForm renders the setup-code entry page — `GET /setup` with a required
|
|
// code not yet entered. A nil gate (not wired) keeps the strict 404; the
|
|
// entrypoint always wires it.
|
|
func (s *Server) serveCodeForm(w http.ResponseWriter, r *http.Request, cid, ip string) {
|
|
s.serveGatePage(w, r, cid, ip, "code form", func(g SetupGate) error { return g.CodeForm(w, r) })
|
|
}
|
|
|
|
// serveLanding renders the page `GET /` shows while `setup: true` (D-045).
|
|
func (s *Server) serveLanding(w http.ResponseWriter, r *http.Request, cid, ip string) {
|
|
s.serveGatePage(w, r, cid, ip, "setup landing", func(g SetupGate) error { return g.Landing(w, r) })
|
|
}
|
|
|
|
// serveLocked renders the "enable setup" boilerplate (D-045).
|
|
func (s *Server) serveLocked(w http.ResponseWriter, r *http.Request, cid, ip string) {
|
|
s.serveGatePage(w, r, cid, ip, "locked boilerplate", func(g SetupGate) error { return g.Locked(w, r) })
|
|
}
|
|
|
|
// serveGatePage hands off to a first-run page rendered by the gate, mapping a
|
|
// render fault to a logged 500. A nil gate keeps the strict 404 — the
|
|
// entrypoint always wires it.
|
|
func (s *Server) serveGatePage(w http.ResponseWriter, r *http.Request, cid, ip, label string, render func(SetupGate) error) {
|
|
if s.gate == nil {
|
|
s.writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
if err := render(s.gate); err != nil {
|
|
s.log.Error("setup: render "+label, "correlation_id", cid, "client_ip", ip, "error", err)
|
|
s.writeError(w, http.StatusInternalServerError, "internal error")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// M1c: the door pins the action door to the `setup` module. Without this a
|
|
// `do=core&action=modules.toggle` under /setup would reach a core action
|
|
// that the session-gated role gate (admin-only) would otherwise protect.
|
|
if res.Module != setupModule {
|
|
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).
|
|
// Over https the cookie name carries the `__Host-` prefix (L1s/D-043).
|
|
secure := httpx.SecureFromRequest(r)
|
|
cookieName := httpx.SessionCookieNameFor(secure)
|
|
cookie, err := r.Cookie(cookieName)
|
|
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, cookieName, 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))
|
|
// SPEC-010 §3/§6: attach the shell decoration — the role-gated nav and the
|
|
// post-setup passkey banner — so every authenticated page renders it
|
|
// consistently (web.PageFor reads it). The door already loaded the user for
|
|
// the role gate, so this adds no extra user lookup.
|
|
ctx = web.WithShell(ctx, u.Role == users.RoleAdmin, s.passkeyBanner(ctx, r, u, idn))
|
|
r = r.WithContext(ctx)
|
|
|
|
s.invoke(w, r, h, newRequestContext(res, sess.UserID, cid, ip))
|
|
}
|
|
|
|
// passkeyBanner reports whether the shell should show the prominent post-setup
|
|
// "add a passkey" prompt for this request (SPEC-010 §6): the signed-in user has
|
|
// no passkey, has NOT opted out, and the origin is not loopback. Any lookup
|
|
// error fails safe — no banner.
|
|
//
|
|
// It is deliberately the door's call, not a handler's: the door is the one
|
|
// place that already holds the session, the identity and the user, so the
|
|
// banner is consistent on the dashboard, the Modules page, the account page and
|
|
// the passkey screen alike. It mirrors the SPEC-006 §8 nudge predicate
|
|
// (opt-out · loopback · no passkey) but is a separate surface — the nudge
|
|
// redirects a TOTP login; this decorates an already-signed-in page.
|
|
func (s *Server) passkeyBanner(ctx context.Context, r *http.Request, u users.User, idn auth.Identity) bool {
|
|
if s.creds == nil || u.PasskeyOptOut {
|
|
return false
|
|
}
|
|
if httpx.IsLoopbackHost(s.originHost(r)) {
|
|
return false
|
|
}
|
|
has, err := s.creds.HasWebAuthnCredential(ctx, idn.ID)
|
|
if err != nil {
|
|
s.log.Warn("door: passkey banner lookup failed", "error", err)
|
|
return false
|
|
}
|
|
return !has
|
|
}
|
|
|
|
// originHost is the origin the banner is judged against: the CONFIGURED
|
|
// base_url's host when set — so a reverse proxy that rewrites `Host` cannot
|
|
// wrongly suppress (or enable) it — falling back to the request `Host` (L7c,
|
|
// the same signal as the SPEC-006 nudge).
|
|
func (s *Server) originHost(r *http.Request) string {
|
|
if u, err := url.Parse(strings.TrimSpace(s.cfg.Server.BaseURL)); err == nil && u.Host != "" {
|
|
return u.Host
|
|
}
|
|
return r.Host
|
|
}
|
|
|
|
// 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"))
|
|
}
|
|
|
|
// 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)
|