NextMyDay/internal/core/auth/flow/flow.go
Claus Lohmar 76a4a6950c feat(auth): add WebAuthn, TOTP and the enroll/login flow (SPEC-004/006)
- 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
2026-10-14 10:53:53 +00:00

738 lines
25 KiB
Go

// Package flow implements SPEC-006: the GUID-driven enroll/login state machine.
//
// GET /{GUID} renders the enrollment screen (pending, in window) or the login
// screen (active, no session); the POST actions run the WebAuthn and TOTP
// ceremonies (`do=auth&action=…`) and mint, rotate or revoke the session.
//
// Two factors only — Passkey (primary) + TOTP (fallback) (D-005). Enrollment
// completes ONLY when BOTH a passkey credential AND a verified TOTP code are
// present (AC-2); outside the bounded window it is refused (A1/AC-4). The
// /setup wizard is the one exception (D-041/TOTPOnlyEnroll): it enrolls TOTP
// only, because a loopback passkey is unusable at the public domain — a nudged
// TOTP login later adds one (§8).
//
// The package holds no HTTP routing and no storage of its own: it drives the
// core auth services and renders the two screens. The door verifies the
// pre-session CSRF token before invoking an action (A15).
//
// Spec: docs/specs/spec-006-enroll-login.md.
package flow
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"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/auth/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// CeremonyCookieName carries the signed WebAuthn session data between begin and
// finish (no server-side table needed).
const CeremonyCookieName = "myday_webauthn"
// Deps assembles the flow's dependencies.
type Deps struct {
// Identities resolves GUIDs, activates identities and checks the window.
Identities auth.Identities
// Credentials stores the WebAuthn credentials.
Credentials auth.Credentials
// Sessions mints/rotates/revokes the credential.
Sessions auth.Sessions
// Users loads the display name for the WebAuthn user entity.
Users users.Users
// WebAuthn is the ceremony seam (fake in tests).
WebAuthn webauthn.Service
// TOTP issues and verifies the second factor.
TOTP totp.Service
// CSRF issues/verifies the pre-session double-submit token and lends its
// signer for the ceremony cookie.
CSRF *httpx.PreSessionCSRF
// Audit receives the enroll/login/logout events (optional).
Audit audit.Audit
// SuccessPath, when set, overrides where a successful enroll/login lands
// (default "/{guid}/home"). SPEC-005's wizard injects "/setup" so the
// bootstrap stays on the wizard until it is finished.
SuccessPath func(guid string) string
// TOTPOnlyEnroll activates a PENDING identity on a verified TOTP code
// ALONE (D-041). The /setup wizard sets it: a passkey registered over
// plain-HTTP loopback binds RP ID 127.0.0.1 and is unusable at the public
// domain (O-13). Default false keeps SPEC-006 §4's mandatory two factors.
TOTPOnlyEnroll bool
// SuppressNudge, when non-nil and true, disables the passkey nudge
// (SPEC-006 §8). The door wires it to setup mode, where a passkey is
// useless. nil = the nudge runs (subject to the other conditions).
SuppressNudge func() bool
// Secure marks cookies Secure (https).
Secure bool
// Logger receives structured logs. Defaults to slog.Default().
Logger *slog.Logger
// Now is the clock (tests).
Now func() time.Time
}
// Actions is the SPEC-006 flow engine.
type Actions struct {
d Deps
}
// New validates and builds the flow.
func New(d Deps) (*Actions, error) {
switch {
case d.Identities == nil:
return nil, errors.New("flow: Identities is required")
case d.Credentials == nil:
return nil, errors.New("flow: Credentials is required")
case d.Sessions == nil:
return nil, errors.New("flow: Sessions is required")
case d.WebAuthn == nil:
return nil, errors.New("flow: WebAuthn is required")
case d.TOTP == nil:
return nil, errors.New("flow: TOTP is required")
case d.CSRF == nil:
return nil, errors.New("flow: CSRF is required")
}
if d.Logger == nil {
d.Logger = slog.Default()
}
if d.Now == nil {
d.Now = time.Now
}
return &Actions{d: d}, nil
}
// ── screens ─────────────────────────────────────────────────────────────────
// EnrollScreen renders the enrollment screen for a pending GUID (SPEC-006 §4).
func (a *Actions) EnrollScreen(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, ok, err := a.pendingInWindow(ctx, guid)
if err != nil {
return err
}
if !ok {
// Unknown, revoked, already active or past the window. An already-active
// identity is sent to the app; everything else is a generic 404 (A1).
if idn.Status == auth.StatusActive {
http.Redirect(w, r, "/"+guid+"/home", http.StatusFound)
return nil
}
writeError(w, http.StatusNotFound, "not found")
return nil
}
token, err := a.d.CSRF.Ensure(w, r)
if err != nil {
return err
}
return renderHTML(w, http.StatusOK, enrollPage(guid, token))
}
// LoginScreen renders the login screen for an active GUID with no session
// (SPEC-006 §5). It answers 200 — a rendered page is a successful response;
// only a FAILED credential attempt is 401/400 (F-1, SPEC-005 §7).
func (a *Actions) LoginScreen(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolve(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
if idn.Status != auth.StatusActive {
writeError(w, http.StatusNotFound, "not found")
return nil
}
token, err := a.d.CSRF.Ensure(w, r)
if err != nil {
return err
}
return renderHTML(w, http.StatusOK, loginPage(guid, token))
}
// ── enrollment actions ──────────────────────────────────────────────────────
// EnrollBegin starts the WebAuthn registration ceremony (SPEC-006 §4).
func (a *Actions) EnrollBegin(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, ok, err := a.pendingInWindow(ctx, guid)
if err != nil {
return err
}
if !ok {
writeError(w, http.StatusNotFound, "not found")
return nil
}
creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID)
if err != nil {
return err
}
begin, err := a.d.WebAuthn.BeginRegistration(ctx, a.webauthnUser(ctx, idn), creds)
if err != nil {
// A ceremony error is a client-side problem, not a server fault.
a.d.Logger.Warn("flow: begin registration failed", "error", err)
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
if err := a.putCeremony(w, begin); err != nil {
return err
}
return writeJSON(w, http.StatusOK, begin.Options)
}
// EnrollFinish completes the WebAuthn registration and stores the credential.
// It does NOT enroll the identity — TOTP must still follow (AC-2).
func (a *Actions) EnrollFinish(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, ok, err := a.pendingInWindow(ctx, guid)
if err != nil {
return err
}
if !ok {
writeError(w, http.StatusNotFound, "not found")
return nil
}
begin, err := a.takeCeremony(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
cred, err := a.d.WebAuthn.FinishRegistration(ctx, a.webauthnUser(ctx, idn), begin, r)
if err != nil {
a.d.Logger.Warn("flow: finish registration failed", "error", err)
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
if err := a.d.Credentials.PutWebAuthnCredential(ctx, idn.ID, cred); err != nil {
return err
}
a.audit(ctx, "enroll.passkey", idn, "passkey registered")
return writeJSON(w, http.StatusOK, []byte(`{"status":"credential-stored"}`))
}
// TOTPBegin issues (and encrypts) the TOTP secret and returns the otpauth URL.
func (a *Actions) TOTPBegin(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, ok, err := a.pendingInWindow(ctx, guid)
if err != nil {
return err
}
if !ok {
writeError(w, http.StatusNotFound, "not found")
return nil
}
issued, err := a.d.TOTP.Begin(ctx, idn.ID, idn.GUID)
if err != nil {
return err
}
a.audit(ctx, "enroll.totp.begin", idn, "secret issued")
payload, err := json.Marshal(map[string]string{"otpauth_url": issued.URL})
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, payload)
}
// TOTPVerify verifies a code. For a PENDING identity it completes enrollment —
// but ONLY when a passkey credential already exists (two-factor, AC-2), unless
// TOTPOnlyEnroll is set (the /setup wizard, D-041). For an ACTIVE identity it
// is the login fallback (SPEC-006 §5) and may trigger the passkey nudge (§8).
func (a *Actions) TOTPVerify(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolve(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
code := r.FormValue("code")
switch idn.Status {
case auth.StatusActive:
// Login fallback (Passkey is primary; this is device-loss recovery).
if err := a.d.TOTP.Verify(ctx, idn.ID, code); err != nil {
a.failAuth(w)
return nil
}
a.audit(ctx, "login.totp", idn, "totp")
// The passkey nudge (SPEC-006 §8): a TOTP login with no passkey, on a
// non-loopback origin, is nudged to add one. A TOTP login WITH a
// passkey is never nudged; nor is a passkey login (login.finish).
target := ""
if a.shouldNudge(ctx, r, idn) {
target = nudgePath(idn.GUID)
}
return a.establishSessionAt(w, r, idn, target)
case auth.StatusPendingEnrollment:
inWindow, err := a.d.Identities.WithinEnrollmentWindow(ctx, idn.ID)
if err != nil {
return err
}
if !inWindow {
writeError(w, http.StatusNotFound, "not found")
return nil
}
// Both factors are mandatory for a normal enrollment (AC-2). The
// /setup wizard is the exception: it enrolls TOTP only (D-041), since
// a loopback-enrolled passkey is unusable at the public domain.
detail := "passkey+totp"
if a.d.TOTPOnlyEnroll {
detail = "totp"
} else {
hasPasskey, err := a.d.Credentials.HasWebAuthnCredential(ctx, idn.ID)
if err != nil {
return err
}
if !hasPasskey {
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
}
if err := a.d.TOTP.Verify(ctx, idn.ID, code); err != nil {
a.failAuth(w)
return nil
}
if err := a.d.Identities.Activate(ctx, idn.ID); err != nil {
return err
}
a.audit(ctx, "enroll.complete", idn, detail)
return a.establishSession(w, r, idn)
default: // revoked — tombstoned
writeError(w, http.StatusNotFound, "not found")
return nil
}
}
// ── login actions ───────────────────────────────────────────────────────────
// LoginBegin starts the WebAuthn assertion ceremony (SPEC-006 §5).
func (a *Actions) LoginBegin(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID)
if err != nil {
return err
}
if len(creds) == 0 {
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
begin, err := a.d.WebAuthn.BeginLogin(ctx, a.webauthnUser(ctx, idn), creds)
if err != nil {
a.d.Logger.Warn("flow: begin login failed", "error", err)
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
if err := a.putCeremony(w, begin); err != nil {
return err
}
return writeJSON(w, http.StatusOK, begin.Options)
}
// LoginFinish verifies the assertion and mints (or rotates) the session.
func (a *Actions) LoginFinish(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
begin, err := a.takeCeremony(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID)
if err != nil {
return err
}
cred, err := a.d.WebAuthn.FinishLogin(ctx, a.webauthnUser(ctx, idn), begin, r, creds)
if err != nil {
a.d.Logger.Warn("flow: finish login failed", "error", err)
a.failAuth(w)
return nil
}
if err := a.d.Credentials.UpdateWebAuthnSignCount(ctx, idn.ID, cred.ID, cred.SignCount); err != nil {
return err
}
a.audit(ctx, "login.passkey", idn, "passkey")
return a.establishSession(w, r, idn)
}
// Logout revokes the current session and clears the cookie (SPEC-006 §5).
func (a *Actions) Logout(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
if c, err := r.Cookie(httpx.SessionCookieName); err == nil && c.Value != "" {
if err := a.d.Sessions.Revoke(ctx, c.Value); err != nil {
a.d.Logger.Warn("flow: logout revoke failed", "error", err)
}
}
httpx.ClearSessionCookie(w, httpx.SessionCookieName, a.d.Secure)
http.Redirect(w, r, "/home", http.StatusSeeOther)
return nil
}
// ── internals ───────────────────────────────────────────────────────────────
// resolve loads the identity for a GUID. A missing or tombstoned GUID reports
// auth.ErrIdentityNotFound (mapped to 404); any other error is a real fault and
// is propagated (500).
func (a *Actions) resolve(ctx context.Context, guid string) (auth.Identity, error) {
if guid == "" {
return auth.Identity{}, auth.ErrIdentityNotFound
}
idn, err := a.d.Identities.ByGUID(ctx, guid)
if err != nil {
return auth.Identity{}, err
}
if idn.Status == auth.StatusRevoked {
return auth.Identity{}, auth.ErrIdentityNotFound
}
return idn, nil
}
// resolveActive resolves a GUID that must map to an ACTIVE identity.
func (a *Actions) resolveActive(ctx context.Context, guid string) (auth.Identity, error) {
idn, err := a.resolve(ctx, guid)
if err != nil {
return auth.Identity{}, err
}
if idn.Status != auth.StatusActive {
return auth.Identity{}, auth.ErrIdentityNotFound
}
return idn, nil
}
// pendingInWindow resolves a GUID that must map to a pending identity still
// inside its bounded enrollment window (A1). The returned identity is also
// present when ok=false so the caller can distinguish "already active".
func (a *Actions) pendingInWindow(ctx context.Context, guid string) (auth.Identity, bool, error) {
idn, err := a.resolve(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
return auth.Identity{}, false, nil
}
return auth.Identity{}, false, err
}
if idn.Status != auth.StatusPendingEnrollment {
return idn, false, nil
}
inWindow, err := a.d.Identities.WithinEnrollmentWindow(ctx, idn.ID)
if err != nil {
return auth.Identity{}, false, err
}
return idn, inWindow, nil
}
// webauthnUser builds the ceremony user for an identity.
func (a *Actions) webauthnUser(ctx context.Context, idn auth.Identity) webauthn.User {
display := idn.GUID
if a.d.Users != nil {
if u, err := a.d.Users.Get(ctx, idn.UserID); err == nil && u.DisplayName != "" {
display = u.DisplayName
}
}
return webauthn.User{
ID: handleFor(idn.UserID),
Name: idn.GUID,
DisplayName: display,
}
}
// handleFor derives the WebAuthn user handle from the stable user id.
func handleFor(userID int64) []byte {
return []byte(fmt.Sprintf("myday-user-%d", userID))
}
// establishSession mints a session on success, rotating a live one when the
// request already carries a valid session for the same identity (fixation
// defence, SPEC-006 §5), then redirects to the app.
func (a *Actions) establishSession(w http.ResponseWriter, r *http.Request, idn auth.Identity) error {
return a.establishSessionAt(w, r, idn, "")
}
// establishSessionAt is establishSession with an optional explicit landing
// path (the passkey nudge overrides the default success target, SPEC-006 §8).
func (a *Actions) establishSessionAt(w http.ResponseWriter, r *http.Request, idn auth.Identity, target string) error {
sess, err := a.issueSession(r, idn)
if err != nil {
return err
}
httpx.SetSessionCookie(w, httpx.SessionCookieName, sess.ID, a.d.Secure)
if target == "" {
target = a.successTarget(idn.GUID)
}
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
// successTarget is where a successful flow lands: the configured SuccessPath
// (the wizard's "/setup") or the user's dashboard.
func (a *Actions) successTarget(guid string) string {
if a.d.SuccessPath != nil {
if p := a.d.SuccessPath(guid); p != "" {
return p
}
}
return "/" + guid + "/home"
}
// nudgePath is where a TOTP login lands when the passkey nudge applies
// (SPEC-006 §8): the session-gated passkey screen carried by the `auth` module.
func nudgePath(guid string) string { return "/" + guid + "/auth/passkey" }
// shouldNudge reports whether the passkey nudge applies to a TOTP login
// (SPEC-006 §8): the identity has NO passkey, the user has not opted out, the
// origin is NOT loopback, and the nudge is not suppressed (setup mode). Any
// lookup error fails safe — no nudge.
func (a *Actions) shouldNudge(ctx context.Context, r *http.Request, idn auth.Identity) bool {
if a.d.SuppressNudge != nil && a.d.SuppressNudge() {
return false
}
if httpx.IsLoopbackHost(r.Host) {
return false
}
if a.d.Users == nil {
return false
}
optOut, err := a.d.Users.PasskeyOptOut(ctx, idn.UserID)
if err != nil {
a.d.Logger.Warn("flow: passkey opt-out lookup failed", "error", err)
return false
}
if optOut {
return false
}
hasPasskey, err := a.d.Credentials.HasWebAuthnCredential(ctx, idn.ID)
if err != nil {
a.d.Logger.Warn("flow: credential lookup failed", "error", err)
return false
}
return !hasPasskey
}
// ── the passkey nudge (SPEC-006 §8, D-041) ──────────────────────────────────
// PasskeyPrompt renders the passkey nudge screen. It is a session-gated ROUTE
// on the `auth` module (GET /{GUID}/auth/passkey) — the redirect target of a
// nudged TOTP login. It BEGINS the WebAuthn registration ceremony and embeds
// the options for the browser, so the single `passkey.add` action completes it.
//
// The door has already validated the session, so the forms carry the
// SESSION-bound CSRF token (SPEC-003 §6) — A15 does not apply.
func (a *Actions) PasskeyPrompt(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID)
if err != nil {
return err
}
begin, err := a.d.WebAuthn.BeginRegistration(ctx, a.webauthnUser(ctx, idn), creds)
if err != nil {
a.d.Logger.Warn("flow: begin passkey registration failed", "error", err)
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
if err := a.putCeremony(w, begin); err != nil {
return err
}
return renderHTML(w, http.StatusOK, passkeyPage(guid, httpx.TokenFromRequest(r), string(begin.Options)))
}
// PasskeyAdd completes the WebAuthn registration begun by PasskeyPrompt and
// stores the credential (audited, AC-11). It is session-gated by the door.
func (a *Actions) PasskeyAdd(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
begin, err := a.takeCeremony(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
cred, err := a.d.WebAuthn.FinishRegistration(ctx, a.webauthnUser(ctx, idn), begin, r)
if err != nil {
a.d.Logger.Warn("flow: finish passkey registration failed", "error", err)
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
if err := a.d.Credentials.PutWebAuthnCredential(ctx, idn.ID, cred); err != nil {
return err
}
a.audit(ctx, "passkey.add", idn, "passkey registered from the login nudge")
http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther)
return nil
}
// PasskeyLater skips the nudge this time. Nothing is persisted (SPEC-006 §8) —
// the user is nudged again at the next TOTP login.
func (a *Actions) PasskeyLater(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther)
return nil
}
// PasskeyNever records the persistent opt-out (users.passkey_opt_out, D-041).
// The write is audited by the users service (A11); later TOTP logins never
// nudge again. The marker survives identity rotation.
func (a *Actions) PasskeyNever(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
if a.d.Users == nil {
return errors.New("flow: Users is required for the passkey opt-out")
}
if err := a.d.Users.SetPasskeyOptOut(ctx, idn.UserID, true); err != nil {
return err
}
http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther)
return nil
}
// issueSession rotates a matching live session, else mints a fresh one.
func (a *Actions) issueSession(r *http.Request, idn auth.Identity) (auth.Session, error) {
ctx := r.Context()
var ip string
if m, ok := audit.MetaFrom(ctx); ok {
ip = m.ClientIP
}
if c, err := r.Cookie(httpx.SessionCookieName); err == nil && c.Value != "" {
if old, err := a.d.Sessions.Validate(ctx, c.Value); err == nil && old.IdentityID == idn.ID {
return a.d.Sessions.Rotate(ctx, c.Value)
}
}
return a.d.Sessions.Mint(ctx, idn.ID, ip, r.UserAgent())
}
// putCeremony signs the WebAuthn state and stores it in a short-lived cookie.
func (a *Actions) putCeremony(w http.ResponseWriter, b *webauthn.Begin) error {
raw, err := json.Marshal(b)
if err != nil {
return fmt.Errorf("flow: marshal ceremony: %w", err)
}
http.SetCookie(w, &http.Cookie{
Name: CeremonyCookieName,
Value: a.d.CSRF.Signer().Sign(string(raw)),
Path: "/",
HttpOnly: true,
Secure: a.d.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: 300,
})
return nil
}
// takeCeremony reads and clears the ceremony cookie.
func (a *Actions) takeCeremony(w http.ResponseWriter, r *http.Request) (*webauthn.Begin, error) {
c, err := r.Cookie(CeremonyCookieName)
if err != nil || c.Value == "" {
return nil, errors.New("flow: missing ceremony state")
}
raw, ok := a.d.CSRF.Signer().Unsign(c.Value)
if !ok {
return nil, errors.New("flow: invalid ceremony state")
}
var b webauthn.Begin
if err := json.Unmarshal([]byte(raw), &b); err != nil {
return nil, fmt.Errorf("flow: decode ceremony: %w", err)
}
// Single use.
http.SetCookie(w, &http.Cookie{
Name: CeremonyCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: a.d.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
return &b, nil
}
// audit records a flow event. The actor is the affected user (they proved it);
// the identity is the subject.
func (a *Actions) audit(ctx context.Context, action string, idn auth.Identity, detail string) {
if a.d.Audit == nil {
return
}
a.d.Audit.Record(ctx, audit.Event{
ActorUserID: idn.UserID,
Action: action,
Subject: fmt.Sprintf("identity:%d", idn.ID),
Detail: detail,
})
}
// failAuth answers with a terse, generic 401 — never revealing which factor
// failed, whether the identity exists, or why (A3).
func (a *Actions) failAuth(w http.ResponseWriter) {
writeError(w, http.StatusUnauthorized, "unauthorized")
}
// 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"))
}
// writeJSON writes a JSON body. Every ceremony response is `no-store` (F-2):
// the TOTP response embeds the otpauth URL, which contains the secret.
func writeJSON(w http.ResponseWriter, status int, body []byte) error {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, err := w.Write(body)
return err
}