diff --git a/internal/core/setup/render.go b/internal/core/setup/render.go new file mode 100644 index 0000000..ab5736e --- /dev/null +++ b/internal/core/setup/render.go @@ -0,0 +1,95 @@ +package setup + +import ( + "html" + "net/http" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" +) + +// renderHTML writes the wizard page inside the core shell (SPEC-009 §4). The +// door renders no dynamic HTML itself (SPEC-003 §4); the wizard is the hand-off +// target for the `setup` workflow. +// +// F-2: the bootstrap renders secrets and ceremony state — never cache it. +func renderHTML(w http.ResponseWriter, status int, page web.Page) error { + w.Header().Set("Cache-Control", "no-store") + return web.Render(w, status, page) +} + +// writeError writes a terse plain-text error. +func 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")) +} + +// redirect sends the operator back to the wizard (post/redirect/get). +func redirect(w http.ResponseWriter, r *http.Request, path string) { + http.Redirect(w, r, path, http.StatusSeeOther) +} + +// wizardPage renders the current step inside the shell. The wizard runs before +// any identity exists, so the page is a GUEST screen: no nav (SPEC-009 §4). +// Every form carries the signed pre-session CSRF token (A15). +func wizardPage(st step, token, domain string) web.Page { + c := html.EscapeString(token) + return web.Page{ + Title: "MyDay setup", + Guest: true, + CSRF: token, + Content: web.Markup(`

MyDay setup

+

Bring this instance to life: the domain, the top admin, then mail and locale.

` + + stepBody(st, c, domain)), + } +} + +// stepBody renders the section for one step. +func stepBody(st step, c, domain string) string { + switch st { + case stepDomain: + return `

1. Domain

` + + `
` + + `` + + `` + + `
` + + case stepAdmin: + return `

2. The top admin

` + + `
` + + `` + + `` + + `` + + `
` + + case stepEnroll: + // Step 2 enrolls TOTP only (D-041): a passkey registered over loopback + // would bind RP ID 127.0.0.1 and be dead at the public domain. The + // admin adds a passkey at first login, via the nudge (SPEC-006 §8). + return `

2. Enroll the admin

` + + `

Add an authenticator app. A passkey can be added later, at first sign-in.

` + + `

Authenticator app

` + + `
` + + `` + + `
` + + `
` + + `` + + `` + + `
` + + default: // stepSettings + return `

3. Mail · locale · timezone

` + + `
` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `
` + } +} diff --git a/internal/core/setup/setup.go b/internal/core/setup/setup.go new file mode 100644 index 0000000..ce235d0 --- /dev/null +++ b/internal/core/setup/setup.go @@ -0,0 +1,524 @@ +// Package setup implements SPEC-005: the loopback-only `/setup` wizard that +// brings a fresh instance to life. +// +// The wizard walks three steps — (1) the domain / base URL, (2) the top admin +// with TOTP enrollment, (3) mail · locale · timezone — and finishes by +// persisting the settings to `config.yaml` with `setup: false` (D-035) and +// signalling completion (`Actions.Done`), after which `/setup` 404s (delete, +// don't redirect). +// +// Step 2 enrolls TOTP ONLY (D-041): a passkey registered over plain-HTTP +// loopback binds RP ID 127.0.0.1 and is unusable at the public domain. The +// admin adds a passkey at first login on the real origin, via the passkey +// nudge (SPEC-006 §8). It reuses SPEC-006's TOTP ceremony (flow.Actions) — +// no duplication. +// +// The wizard holds no routing of its own — the door (SPEC-003/005) gates it to +// setup mode and loopback, and verifies the pre-session CSRF token (A15) before +// invoking any action here. +// +// Spec: docs/specs/spec-005-setup-wizard.md. +package setup + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + + "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/users" +) + +// adminUserID is the top admin — the first user (D-013, D-021). +const adminUserID int64 = 1 + +// Users is the narrow slice of the user service the wizard needs. +type Users interface { + // Count reports how many users exist — the fail-safe setup-mode check + // (SPEC-005 §3) and the "is the admin already created?" question. + Count(ctx context.Context) (int, error) + // Create creates the top admin via the bootstrap path (creatorID == 0). + Create(ctx context.Context, creatorID int64, email, displayName string, role users.Role) (users.UserID, error) + // Get loads a user. + Get(ctx context.Context, id int64) (users.User, error) +} + +// Enroller is the SPEC-006 TOTP ceremony, reused so the wizard never +// duplicates the work (SPEC-005 §4). Step 2 enrolls TOTP only (D-041) — the +// wizard never calls the WebAuthn registration actions. +type Enroller interface { + TOTPBegin(w http.ResponseWriter, r *http.Request, guid string) error + TOTPVerify(w http.ResponseWriter, r *http.Request, guid string) error +} + +// Deps assembles the wizard's dependencies. +type Deps struct { + // Config is the startup configuration; the wizard persists a copy of it + // (with `setup: false` and the new settings) to ConfigPath. + Config *config.Config + // ConfigPath is where the settings are persisted to (usually config.yaml, + // never the committed example). + ConfigPath string + // Users creates and inspects the top admin. + Users Users + // Identities mints and resolves the admin's identity. + Identities auth.Identities + // Enroll is the SPEC-006 ceremony (flow.Actions). + Enroll Enroller + // Audit receives the bootstrap events (A11). + Audit audit.Audit + // CSRF issues and verifies the anonymous double-submit token (A15). + CSRF *httpx.PreSessionCSRF + // Logger receives structured logs. Defaults to slog.Default(). + Logger *slog.Logger + // Printout receives the rendered config when the file is not writable. + // Defaults to os.Stdout. + Printout io.Writer + // WriteFile writes the config file (tests inject a failing writer to + // exercise the "print the values" path). Defaults to os.WriteFile 0600. + WriteFile func(path string, data []byte) error +} + +// Actions is the /setup wizard. +type Actions struct { + d Deps + mu sync.Mutex + draft draft + // done signals that the bootstrap is complete. The live config is treated + // as an immutable startup snapshot (D-035: the FILE is the source of + // truth), so completion is a concurrency-safe flag here rather than a + // mutation of config.Setup — the door reads it on every request. + done atomic.Bool +} + +// Done reports whether the wizard has completed — the door's "setup is over" +// signal (SPEC-005 §5). It is safe for concurrent use. +func (a *Actions) Done() bool { return a.done.Load() } + +// draft is the small amount of wizard state that is not yet in the database or +// the config: the base URL captured in step 1 (empty = step 1 unanswered). +type draft struct { + domain string +} + +// New validates and builds the wizard. +func New(d Deps) (*Actions, error) { + switch { + case d.Config == nil: + return nil, errors.New("setup: Config is required") + case d.Users == nil: + return nil, errors.New("setup: Users is required") + case d.Identities == nil: + return nil, errors.New("setup: Identities is required") + case d.Enroll == nil: + return nil, errors.New("setup: Enroll is required") + case d.CSRF == nil: + return nil, errors.New("setup: CSRF is required") + } + if strings.TrimSpace(d.ConfigPath) == "" { + return nil, errors.New("setup: ConfigPath is required") + } + if d.Logger == nil { + d.Logger = slog.Default() + } + if d.Printout == nil { + d.Printout = os.Stdout + } + if d.WriteFile == nil { + d.WriteFile = writeFile0600 + } + return &Actions{d: d}, nil +} + +// ── the wizard page ───────────────────────────────────────────────────────── + +// Page renders the wizard at its current step (GET /setup). +func (a *Actions) Page(w http.ResponseWriter, r *http.Request) error { + st, err := a.step(r.Context()) + if err != nil { + return err + } + token, err := a.d.CSRF.Ensure(w, r) + if err != nil { + return err + } + return renderHTML(w, http.StatusOK, wizardPage(st, token, a.draftDomain())) +} + +// ── step 1 · the domain ───────────────────────────────────────────────────── + +// DomainSave records the base URL (step 1) and advances to step 2. +func (a *Actions) DomainSave(w http.ResponseWriter, r *http.Request) error { + if a.done.Load() { + writeError(w, http.StatusNotFound, "not found") + return nil + } + base := strings.TrimSpace(r.FormValue("base_url")) + if base == "" { + writeError(w, http.StatusBadRequest, "a base URL is required") + return nil + } + if u, err := url.Parse(base); err != nil || u.Scheme == "" || u.Host == "" { + writeError(w, http.StatusBadRequest, "invalid base URL") + return nil + } + a.mu.Lock() + a.draft.domain = base + a.mu.Unlock() + redirect(w, r, "/setup") + return nil +} + +// ── step 2 · the top admin ────────────────────────────────────────────────── + +// AdminCreate creates `user_id = 1` (role admin) and mints its identity. Only +// one admin may ever exist (SPEC-004 AC-6) — a second attempt is refused. +func (a *Actions) AdminCreate(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + if a.done.Load() { + writeError(w, http.StatusNotFound, "not found") + return nil + } + n, err := a.d.Users.Count(ctx) + if err != nil { + return err + } + if n > 0 { + // The admin may already exist. Refuse a SECOND admin (AC-5), but repair + // a first one left without an identity (a crash after Create, before + // Mint) — otherwise the wizard could never be resumed. + repaired, err := a.ensureAdminIdentity(ctx) + if err != nil { + return err + } + if !repaired { + writeError(w, http.StatusConflict, "an admin already exists") + return nil + } + redirect(w, r, "/setup") + return nil + } + + name := strings.TrimSpace(r.FormValue("display_name")) + email := strings.TrimSpace(r.FormValue("email")) + if name == "" || email == "" { + writeError(w, http.StatusBadRequest, "a display name and an email are required") + return nil + } + + userID, err := a.d.Users.Create(ctx, 0, email, name, users.RoleAdmin) + if err != nil { + switch { + case errors.Is(err, users.ErrBootstrapClosed), errors.Is(err, users.ErrOneAdmin), + errors.Is(err, users.ErrDuplicateEmail): + writeError(w, http.StatusConflict, "cannot create the admin") + case errors.Is(err, users.ErrEmailRequired), errors.Is(err, users.ErrNameRequired), + errors.Is(err, users.ErrInvalidRole), errors.Is(err, users.ErrBootstrapAdmin): + writeError(w, http.StatusBadRequest, "bad request") + default: + return err + } + return nil + } + if _, _, err := a.d.Identities.Mint(ctx, userID); err != nil { + return err + } + a.record(ctx, "setup.admin", fmt.Sprintf("user:%d", userID)) + redirect(w, r, "/setup") + return nil +} + +// ensureAdminIdentity mints the top admin's identity when the user exists but +// has none (a crash between Create and Mint). It reports repaired=false when an +// identity already exists — the caller then refuses a second admin (AC-5). +func (a *Actions) ensureAdminIdentity(ctx context.Context) (repaired bool, err error) { + if _, err := a.d.Identities.ByUserID(ctx, adminUserID); err == nil { + return false, nil + } else if !errors.Is(err, auth.ErrIdentityNotFound) { + return false, err + } + u, err := a.d.Users.Get(ctx, adminUserID) + if err != nil { + return false, err + } + if u.Role != users.RoleAdmin { + return false, nil + } + if _, _, err := a.d.Identities.Mint(ctx, adminUserID); err != nil { + return false, err + } + a.record(ctx, "setup.admin", fmt.Sprintf("user:%d", adminUserID)) + return true, nil +} + +// TOTPBegin issues the admin's TOTP secret. +func (a *Actions) TOTPBegin(w http.ResponseWriter, r *http.Request) error { + guid, ok, err := a.pendingGUID(r.Context()) + if err != nil { + return err + } + if !ok { + writeError(w, http.StatusConflict, "no admin is awaiting enrollment") + return nil + } + return a.d.Enroll.TOTPBegin(w, r, guid) +} + +// TOTPVerify verifies the code, activating the identity and minting a session +// (the ceremony lives in SPEC-006). +func (a *Actions) TOTPVerify(w http.ResponseWriter, r *http.Request) error { + guid, ok, err := a.pendingGUID(r.Context()) + if err != nil { + return err + } + if !ok { + writeError(w, http.StatusConflict, "no admin is awaiting enrollment") + return nil + } + return a.d.Enroll.TOTPVerify(w, r, guid) +} + +// ── step 3 · settings & finish ────────────────────────────────────────────── + +// Finish persists the mail/locale/timezone settings (a copy of the startup +// config with `setup: false`), signals completion (`Done`) and lands the freshly +// enrolled admin on their dashboard. +// +// If `config.yaml` is not writable the values are PRINTED for the operator +// (SPEC-005 §4) and setup still completes. +func (a *Actions) Finish(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + if a.done.Load() { + writeError(w, http.StatusNotFound, "not found") + return nil + } + st, err := a.step(ctx) + if err != nil { + return err + } + if st != stepSettings { + writeError(w, http.StatusConflict, "the admin is not enrolled yet") + return nil + } + + port := 0 + if v := strings.TrimSpace(r.FormValue("mail.port")); v != "" { + p, err := strconv.Atoi(v) + if err != nil || p < 0 || p > 65535 { + writeError(w, http.StatusBadRequest, "invalid mail port") + return nil + } + port = p + } + + c := *a.d.Config // a copy: the live config stays the startup snapshot (D-035) + c.Setup = false + c.Mail = config.Mail{ + Host: strings.TrimSpace(r.FormValue("mail.host")), + Port: port, + Username: strings.TrimSpace(r.FormValue("mail.username")), + Password: r.FormValue("mail.password"), + From: strings.TrimSpace(r.FormValue("mail.from")), + } + c.App = config.App{ + Locale: strings.TrimSpace(r.FormValue("app.locale")), + Timezone: strings.TrimSpace(r.FormValue("app.timezone")), + } + if d := a.draftDomain(); d != "" { + c.Server.BaseURL = d + } + + // R-6: a first boot from config.example.yaml must persist a VIABLE config. + // Any secret that is still empty or a placeholder is replaced with a + // strong, freshly generated value before it is written. + if _, err := a.generateSecrets(&c); err != nil { + return err + } + + if err := a.persist(&c); err != nil { + return err + } + a.done.Store(true) + a.record(ctx, "setup.complete", "bootstrap finished") + + target := "/home" + if guid, err := a.adminGUID(ctx); err == nil && guid != "" { + target = "/" + guid + "/home" + } + redirect(w, r, target) + return nil +} + +// generateSecrets replaces any empty or placeholder secret with a strong, +// cryptographically-random value (R-6), so a copy of config.example.yaml boots +// straight into productive mode after the wizard. It reports whether it changed +// anything. +// +// The TOTP encryption key feeds AES-256-GCM, whose key must be EXACTLY 32 +// bytes (SPEC-003 D-2), so it is base64url of 24 random bytes = 32 printable +// characters. The other three are signing keys and only need >= 32 bytes. +func (a *Actions) generateSecrets(c *config.Config) (bool, error) { + changed := false + for _, s := range []struct { + name string + dst *string + n int + }{ + {"secrets.session_key", &c.Secrets.SessionKey, 32}, + {"secrets.cookie_secret", &c.Secrets.CookieSecret, 32}, + {"secrets.csrf_key", &c.Secrets.CSRFKey, 32}, + } { + if !config.SecretIsPlaceholder(*s.dst) { + continue + } + v, err := randomString(s.n) + if err != nil { + return false, err + } + *s.dst = v + changed = true + a.d.Logger.Info("setup: generated a strong secret", "name", s.name) + } + if config.SecretIsPlaceholder(c.Secrets.TOTPEncryptionKey) { + v, err := randomString(24) // 24 bytes -> 32 base64url chars + if err != nil { + return false, err + } + c.Secrets.TOTPEncryptionKey = v + changed = true + a.d.Logger.Info("setup: generated a strong secret", "name", "secrets.totp_encryption_key") + } + return changed, nil +} + +// randomString returns n cryptographically-random bytes, base64url-encoded. +// It delegates to config.GenerateSecret so the wizard and the startup path (the +// TOTP key, R-6) mint secrets the same way. +func randomString(n int) (string, error) { return config.GenerateSecret(n) } + +// persist writes the config to disk; when it cannot, it prints the values and +// instructs the operator (SPEC-005 §4). It is complete either way. +func (a *Actions) persist(c *config.Config) error { + data, err := config.Marshal(c) + if err != nil { + return err + } + if err := a.d.WriteFile(a.d.ConfigPath, data); err != nil { + a.printValues(data, err) + return nil + } + a.d.Logger.Info("setup: settings persisted", "path", a.d.ConfigPath) + return nil +} + +// printValues is the operator fallback when config.yaml is not writable: the +// values go to stdout with an instruction, and setup still completes. +func (a *Actions) printValues(data []byte, cause error) { + a.d.Logger.Warn("setup: config file is not writable — printing the values for the operator", + "path", a.d.ConfigPath, "error", cause) + fmt.Fprintf(a.d.Printout, + "\n# MyDay setup could not write %s: %v\n"+ + "# Write the following to that path by hand, then restart MyDay:\n\n%s\n", + a.d.ConfigPath, cause, data) +} + +// ── state ─────────────────────────────────────────────────────────────────── + +// step is the wizard's position. +type step int + +const ( + stepDomain step = iota // 1 · the domain / base URL + stepAdmin // 2a · the admin's details + stepEnroll // 2b · TOTP enrollment (D-041: no passkey) + stepSettings // 3 · mail · locale · timezone +) + +// step derives the current step from the database and the in-memory draft. +func (a *Actions) step(ctx context.Context) (step, error) { + n, err := a.d.Users.Count(ctx) + if err != nil { + return 0, err + } + if n == 0 { + if a.draftDomain() != "" { + return stepAdmin, nil + } + return stepDomain, nil + } + + idn, err := a.d.Identities.ByUserID(ctx, adminUserID) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + // A user exists but has no live identity — restart step 2. + return stepAdmin, nil + } + return 0, err + } + if idn.Status == auth.StatusPendingEnrollment { + return stepEnroll, nil + } + return stepSettings, nil +} + +// draftDomain returns the base URL captured in step 1, if any. +func (a *Actions) draftDomain() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.draft.domain +} + +// adminGUID returns the admin's current GUID. +func (a *Actions) adminGUID(ctx context.Context) (string, error) { + idn, err := a.d.Identities.ByUserID(ctx, adminUserID) + if err != nil { + return "", err + } + return idn.GUID, nil +} + +// pendingGUID returns the admin's GUID when its identity still awaits +// enrollment; ok=false means there is nothing to enroll. +func (a *Actions) pendingGUID(ctx context.Context) (guid string, ok bool, err error) { + idn, err := a.d.Identities.ByUserID(ctx, adminUserID) + if err != nil { + if errors.Is(err, auth.ErrIdentityNotFound) { + return "", false, nil + } + return "", false, err + } + if idn.Status != auth.StatusPendingEnrollment { + return "", false, nil + } + return idn.GUID, true, nil +} + +// record writes one bootstrap audit row (A11). +func (a *Actions) record(ctx context.Context, action, detail string) { + if a.d.Audit == nil { + return + } + a.d.Audit.Record(ctx, audit.Event{ + Action: action, + Subject: fmt.Sprintf("user:%d", adminUserID), + Detail: detail, + }) +} + +// writeFile0600 is the default persister: the config holds secrets, so it is +// written owner-only. +func writeFile0600(path string, data []byte) error { + return os.WriteFile(path, data, 0o600) +}