- webauthn: the passkey ceremony service (a fake in tests) - totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9) - sealer: the AEAD TOTP sealer - identities/sessions/credentials: the GUID state machine - flow: the enroll/login screens + the passkey nudge (pre-session double-submit CSRF — A15), rendered inside the shell
129 lines
5.2 KiB
Go
129 lines
5.2 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`.
|
|
func renderHTML(w http.ResponseWriter, status int, page web.Page) error {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
return web.Render(w, status, 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>`),
|
|
}
|
|
}
|