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).
133 lines
5.4 KiB
Go
133 lines
5.4 KiB
Go
package flow
|
|
|
|
import (
|
|
"html"
|
|
"net/http"
|
|
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
|
|
)
|
|
|
|
// renderHTML writes a server-rendered page inside the core shell (SPEC-009 §4).
|
|
// The door renders no dynamic HTML itself (SPEC-003 §4); these are the flow's
|
|
// hand-off targets.
|
|
//
|
|
// F-2: the enrollment screen carries the TOTP ceremony and the login screen a
|
|
// session — neither may be cached, so every flow response is `no-store`.
|
|
//
|
|
// web.PageFor applies the request's shell state (the role-gated nav and the
|
|
// post-setup passkey banner, SPEC-010) — a guest screen (enroll/login) carries
|
|
// none, and returns unchanged.
|
|
func renderHTML(w http.ResponseWriter, r *http.Request, status int, page web.Page) error {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
return web.Render(w, status, web.PageFor(r, page))
|
|
}
|
|
|
|
// enrollPage is the enrollment screen (SPEC-006 §4). The passkey ceremony is
|
|
// driven by the same-origin WebAuthn client (SPEC-009 §5) from the data-*
|
|
// contract on <main>; the TOTP forms work without JavaScript. Every form
|
|
// carries the signed pre-session CSRF token (A15), which the client also reads
|
|
// from the shell's <meta name="csrf-token">.
|
|
func enrollPage(guid, token string) web.Page {
|
|
g := html.EscapeString(guid)
|
|
c := html.EscapeString(token)
|
|
action := func(name string) string {
|
|
return `"/` + g + `?do=auth&action=` + name + `"`
|
|
}
|
|
return web.Page{
|
|
Title: "Enrollment",
|
|
GUID: guid,
|
|
Guest: true,
|
|
CSRF: token,
|
|
MainAttrs: web.Attrs(`data-webauthn="enroll"` +
|
|
` data-begin=` + action("enroll.begin") +
|
|
` data-finish=` + action("enroll.finish")),
|
|
Content: web.Markup(`<h1>Enrollment</h1>
|
|
<p>Two factors are required: a passkey <strong>and</strong> an authenticator app.</p>
|
|
<section aria-label="passkey">
|
|
<h2>1. Passkey</h2>
|
|
<p id="passkey-status" data-webauthn-status>Register a passkey for this device.</p>
|
|
<button type="button" data-webauthn-start>Register a passkey</button>
|
|
<noscript><p>Registering a passkey needs JavaScript in the browser. You can still set up your authenticator app below, and add a passkey later at sign-in.</p></noscript>
|
|
</section>
|
|
<section aria-label="totp">
|
|
<h2>2. Authenticator app</h2>
|
|
<form method="post" action=` + action("totp.begin") + `>
|
|
<input type="hidden" name="csrf" value="` + c + `">
|
|
<button type="submit" class="secondary">Set up authenticator</button></form>
|
|
<form method="post" action=` + action("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>`),
|
|
}
|
|
}
|
|
|
|
// passkeyPage is the post-login passkey nudge (SPEC-006 §8, D-041). The
|
|
// WebAuthn registration is begun server-side and the options are embedded for
|
|
// the browser; the single `passkey.add` action completes it. The two skip forms
|
|
// work without JavaScript. Every form carries the SESSION-bound CSRF token (a
|
|
// session exists here — A15 does not apply).
|
|
func passkeyPage(guid, token, options string) web.Page {
|
|
g := html.EscapeString(guid)
|
|
c := html.EscapeString(token)
|
|
action := func(name string) string {
|
|
return `"/` + g + `?do=auth&action=` + name + `"`
|
|
}
|
|
return web.Page{
|
|
Title: "Add a passkey",
|
|
GUID: guid,
|
|
CSRF: token,
|
|
Flash: "Skip the one-time code next time by adding a passkey for this device.",
|
|
MainAttrs: web.Attrs(`data-webauthn="passkey"` +
|
|
` data-finish=` + action("passkey.add") +
|
|
` data-options="` + html.EscapeString(options) + `"`),
|
|
Content: web.Markup(`<h1>Add a passkey</h1>
|
|
<section aria-label="passkey">
|
|
<p id="passkey-status" data-webauthn-status>Use this device's screen lock or security key.</p>
|
|
<button type="button" data-webauthn-start>Add a passkey</button>
|
|
<noscript><p>Adding a passkey needs JavaScript in the browser.</p></noscript>
|
|
</section>
|
|
<section aria-label="skip">
|
|
<h2>Not now?</h2>
|
|
<form method="post" action=` + action("passkey.later") + `>
|
|
<input type="hidden" name="csrf" value="` + c + `">
|
|
<button type="submit" class="secondary">Not now</button></form>
|
|
<form method="post" action=` + action("passkey.never") + `>
|
|
<input type="hidden" name="csrf" value="` + c + `">
|
|
<button type="submit" class="secondary">Don't ask again</button></form>
|
|
</section>`),
|
|
}
|
|
}
|
|
|
|
// loginPage is the login screen (SPEC-006 §5): Passkey primary, TOTP fallback.
|
|
func loginPage(guid, token string) web.Page {
|
|
g := html.EscapeString(guid)
|
|
c := html.EscapeString(token)
|
|
action := func(name string) string {
|
|
return `"/` + g + `?do=auth&action=` + name + `"`
|
|
}
|
|
return web.Page{
|
|
Title: "Sign in",
|
|
GUID: guid,
|
|
Guest: true,
|
|
CSRF: token,
|
|
MainAttrs: web.Attrs(`data-webauthn="login"` +
|
|
` data-begin=` + action("login.begin") +
|
|
` data-finish=` + action("login.finish")),
|
|
Content: web.Markup(`<h1>Sign in</h1>
|
|
<p>Use a passkey for this device, or your authenticator code.</p>
|
|
<section aria-label="passkey">
|
|
<h2>Passkey</h2>
|
|
<p id="passkey-status" data-webauthn-status>Sign in with a passkey.</p>
|
|
<button type="button" data-webauthn-start>Use a passkey</button>
|
|
<noscript><p>Passkey sign-in needs JavaScript in the browser. Use your authenticator code below.</p></noscript>
|
|
</section>
|
|
<section aria-label="totp">
|
|
<h2>Authenticator code</h2>
|
|
<form method="post" action=` + action("totp.verify") + `>
|
|
<input type="hidden" name="csrf" value="` + c + `">
|
|
<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code"></label>
|
|
<button type="submit">Use an authenticator code</button></form>
|
|
</section>`),
|
|
}
|
|
}
|