The first-run wizard's `admin.totp.begin` delegated to the enroll flow's
`TOTPBegin`, which is `webauthn.js`'s JSON API: the browser was shown raw
`{"otpauth_url":…}` and the wizard stalled at step 2, rendering no secret.
Extract the issue step into a write-free seam, `flow.Actions.IssueTOTP`, and
keep `TOTPBegin`'s JSON contract by having it call the seam. The wizard now
issues via the seam and renders its own HTML page (`text/html`, inside the
shell, no inline scripts) showing the otpauth URL, the human-readable secret
for manual entry, and the verify form (pre-session CSRF, A15).
`setup.Enroller` drops `TOTPBegin` — the wizard no longer calls it, and a
JSON-writing method on a no-JS seam is the coupling that caused the bug.
Tighten the tests that missed it: `setup_wizard_test.go` asserts the `begin`
response is `text/html` and carries the secret and the verify form (not merely
200), keeping the end-to-end begin→code→303 path; `enroll_login_test.go` pins
the enroll flow's JSON contract (`application/json` + `otpauth_url`).
Spec: docs/specs/spec-005-setup-wizard.md §11.
202 lines
8.6 KiB
Go
202 lines
8.6 KiB
Go
package setup
|
|
|
|
import (
|
|
"html"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"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(`<h1>MyDay setup</h1>
|
|
<p>Bring this instance to life: the domain, the top admin, then mail and locale.</p>` +
|
|
stepBody(st, c, domain)),
|
|
}
|
|
}
|
|
|
|
// totpPage renders the wizard's TOTP step (SPEC-005 §4) once the secret has
|
|
// been issued: the otpauth URL, its human-readable secret for manual entry, and
|
|
// the verify form. The wizard is server-rendered and works with no JavaScript,
|
|
// so the secret is shown as text rather than encoded in a QR image; the page
|
|
// renders inside the shell (no inline scripts) and the verify form carries the
|
|
// signed pre-session CSRF token (A15).
|
|
func totpPage(token, otpauthURL string) web.Page {
|
|
c := html.EscapeString(token)
|
|
u := html.EscapeString(otpauthURL)
|
|
secret := html.EscapeString(secretFromOtpauth(otpauthURL))
|
|
return web.Page{
|
|
Title: "MyDay setup",
|
|
Guest: true,
|
|
CSRF: token,
|
|
Content: web.Markup(`<h1>MyDay setup</h1>
|
|
<p>Add the authenticator, then enter a code from it to finish.</p>` +
|
|
`<section aria-label="enroll"><h2>2. Enroll the admin</h2>` +
|
|
`<p>A passkey can be added later, at first sign-in.</p>` +
|
|
`<section aria-label="totp"><h3>Authenticator app</h3>` +
|
|
`<p>Open this link in your authenticator app:</p>` +
|
|
`<p><a href="` + u + `">` + u + `</a></p>` +
|
|
`<p>Or enter this secret by hand:</p>` +
|
|
`<p><code>` + secret + `</code></p>` +
|
|
`<form method="post" action="/setup?do=setup&action=admin.totp.verify">` +
|
|
`<input type="hidden" name="csrf" value="` + c + `">` +
|
|
`<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code"></label>` +
|
|
`<button type="submit">Verify code</button></form></section></section>`),
|
|
}
|
|
}
|
|
|
|
// secretFromOtpauth pulls the shared secret out of an otpauth:// URL for the
|
|
// manual-entry fallback. It returns "" when the URL carries none — the page then
|
|
// shows the link alone. The secret is never logged.
|
|
func secretFromOtpauth(raw string) string {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Query().Get("secret")
|
|
}
|
|
|
|
// codeFormPage renders the D-044 first-run page — what `GET /setup` shows while
|
|
// `setup: true` and a required `setup_code` has not yet been entered. It is
|
|
// deliberately friendly: the point is that the domain must NOT look "down".
|
|
//
|
|
// It is a GUEST screen (no identity, no nav) with the setup-code entry form.
|
|
// The form submits over POST, so the code never rides the URL (and is never
|
|
// access-logged — the `code` parameter is redacted even if it ever did, A8).
|
|
func codeFormPage(token, flash string) web.Page {
|
|
c := html.EscapeString(token)
|
|
kind := ""
|
|
if flash != "" {
|
|
kind = "error"
|
|
}
|
|
return web.Page{
|
|
Title: "MyDay is not set up yet",
|
|
Guest: true,
|
|
CSRF: token,
|
|
Flash: flash,
|
|
FlashKind: kind,
|
|
Content: web.Markup(`<h1>MyDay is not set up yet</h1>
|
|
<p>This instance is waiting for its first-run setup. Open the setup wizard with the one-time
|
|
setup code that MyDay printed to its log when it started.</p>
|
|
<form method="post" action="/setup?do=setup&action=unlock">
|
|
<input type="hidden" name="csrf" value="` + c + `">
|
|
<label>Setup code <input name="code" autocomplete="off" autocapitalize="off" spellcheck="false" autofocus required></label>
|
|
<button type="submit">Unlock setup</button>
|
|
</form>
|
|
<section aria-label="where to find the code">
|
|
<h2>Where is the code?</h2>
|
|
<p>It is in MyDay's startup log:</p>
|
|
<ul>
|
|
<li>in the container: <code>journalctl -u myday</code></li>
|
|
<li>from the Proxmox host: <code>pct exec 11020 -- journalctl -u myday</code></li>
|
|
</ul>
|
|
<p>Look for the line <code>setup code</code>.</p>
|
|
</section>`),
|
|
}
|
|
}
|
|
|
|
// landingPage is the friendly page `GET /` shows while `setup: true` (D-045) —
|
|
// a pointer to the wizard. It carries no form and no code.
|
|
func landingPage() web.Page {
|
|
return web.Page{
|
|
Title: "MyDay setup",
|
|
Guest: true,
|
|
Content: web.Markup(`<h1>MyDay is ready for setup</h1>
|
|
<p>The setup wizard is enabled on this instance.</p>
|
|
<p><a href="/setup">Open the setup wizard</a></p>`),
|
|
}
|
|
}
|
|
|
|
// lockedPage is what `GET /` and `GET /setup` show when `setup: false` and no
|
|
// admin exists yet (D-045): the instance is deliberately locked, not claimable.
|
|
// It is a GUEST screen with no form.
|
|
func lockedPage() web.Page {
|
|
return web.Page{
|
|
Title: "MyDay is not set up",
|
|
Guest: true,
|
|
Content: web.Markup(`<h1>MyDay is not set up</h1>
|
|
<p>This instance has no administrator yet, and setup is disabled.</p>
|
|
<p>To run the first-run setup, set <code>setup: true</code> in <code>config.yaml</code> and
|
|
restart MyDay. Then open <code>/setup</code>.</p>`),
|
|
}
|
|
}
|
|
|
|
// stepBody renders the section for one step.
|
|
func stepBody(st step, c, domain string) string {
|
|
switch st {
|
|
case stepDomain:
|
|
return `<section aria-label="domain"><h2>1. Domain</h2>` +
|
|
`<form method="post" action="/setup?do=setup&action=domain.save">` +
|
|
`<input type="hidden" name="csrf" value="` + c + `">` +
|
|
`<label>Base URL <input name="base_url" type="url" value="` +
|
|
html.EscapeString(domain) + `" placeholder="https://myday.example.com" required></label>` +
|
|
`<button type="submit">Continue</button></form></section>`
|
|
|
|
case stepAdmin:
|
|
return `<section aria-label="admin"><h2>2. The top admin</h2>` +
|
|
`<form method="post" action="/setup?do=setup&action=admin.create">` +
|
|
`<input type="hidden" name="csrf" value="` + c + `">` +
|
|
`<label>Display name <input name="display_name" autocomplete="name" required></label>` +
|
|
`<label>Email <input name="email" type="email" autocomplete="email" required></label>` +
|
|
`<button type="submit">Create the admin</button></form></section>`
|
|
|
|
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 `<section aria-label="enroll"><h2>2. Enroll the admin</h2>` +
|
|
`<p>Add an authenticator app. A passkey can be added later, at first sign-in.</p>` +
|
|
`<section aria-label="totp"><h3>Authenticator app</h3>` +
|
|
`<form method="post" action="/setup?do=setup&action=admin.totp.begin">` +
|
|
`<input type="hidden" name="csrf" value="` + c + `">` +
|
|
`<button type="submit" class="secondary">Set up authenticator</button></form>` +
|
|
`<form method="post" action="/setup?do=setup&action=admin.totp.verify">` +
|
|
`<input type="hidden" name="csrf" value="` + c + `">` +
|
|
`<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code"></label>` +
|
|
`<button type="submit">Verify code</button></form></section></section>`
|
|
|
|
default: // stepSettings
|
|
return `<section aria-label="settings"><h2>3. Mail · locale · timezone</h2>` +
|
|
`<form method="post" action="/setup?do=setup&action=finish">` +
|
|
`<input type="hidden" name="csrf" value="` + c + `">` +
|
|
`<label>SMTP host <input name="mail.host"></label>` +
|
|
`<label>SMTP port <input name="mail.port" inputmode="numeric"></label>` +
|
|
`<label>SMTP username <input name="mail.username"></label>` +
|
|
`<label>SMTP password <input name="mail.password" type="password"></label>` +
|
|
`<label>From address <input name="mail.from" type="email"></label>` +
|
|
`<label>Locale <input name="app.locale" value="en-GB"></label>` +
|
|
`<label>Timezone <input name="app.timezone" value="Europe/London"></label>` +
|
|
`<button type="submit">Finish setup</button></form></section>`
|
|
}
|
|
}
|