Implements D-025 (revised 2026-10-15) and D-043. Setup gate (D-025): - Remove the bind refusal (CheckSetupBind/ErrNonLoopbackSetup/server.IsLoopback); Serve binds any address, so the app can listen on 0.0.0.0:8080 behind Zoraxy. - The gate is now per request and strict (httpx.SetupRequestAllowed): a loopback TCP peer AND no X-Forwarded-For — the direct SSH tunnel. Anything else is 404 before dispatch; an unparseable peer fails closed (AC-7/AC-10). The raw XFF is read before ClientIP SETS it (A6). Proxy scheme (D-043): - httpx.RequestIsHTTPS resolves the scheme per request: direct TLS, or a trusted proxy's X-Forwarded-Proto. The door carries it on the request context (httpx.WithSecure) so the session cookie, its __Host- prefix, the pre-session and ceremony cookies, and HSTS all agree (AC-13). - Over plain http (the tunnel) cookies stay bare and non-Secure, which fixes the bootstrap from an https:// config.example.yaml; Config.IsSecure() is removed as now dead. Tests: rewrite the setup-gate tests for AC-7/AC-10, add AC-13, and unit-test SetupRequestAllowed / RequestIsHTTPS.
755 lines
26 KiB
Go
755 lines
26 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"
|
|
"net/url"
|
|
"strings"
|
|
"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
|
|
// BaseURL is the CONFIGURED public origin. It decides the nudge's origin
|
|
// signal: a proxy that rewrites `Host` must not flip the decision (L7c).
|
|
// Empty falls back to the request `Host`.
|
|
BaseURL string
|
|
// 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, r, 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, r, 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()
|
|
// The cookie name/Secure flag follow the request scheme (D-043).
|
|
secure := httpx.SecureFromRequest(r)
|
|
if c, err := r.Cookie(httpx.SessionCookieNameFor(secure)); 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.SessionCookieNameFor(secure), 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
|
|
}
|
|
secure := httpx.SecureFromRequest(r) // per-request scheme (D-043)
|
|
httpx.SetSessionCookie(w, httpx.SessionCookieNameFor(secure), sess.ID, 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(a.originHost(r)) {
|
|
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
|
|
}
|
|
|
|
// originHost is the origin the nudge is judged against (L7c): the CONFIGURED
|
|
// base_url's host when set — so a reverse proxy that rewrites `Host` cannot
|
|
// wrongly suppress (or enable) the nudge — falling back to the request `Host`.
|
|
func (a *Actions) originHost(r *http.Request) string {
|
|
if u, err := url.Parse(strings.TrimSpace(a.d.BaseURL)); err == nil && u.Host != "" {
|
|
return u.Host
|
|
}
|
|
return r.Host
|
|
}
|
|
|
|
// ── 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, r, 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.SessionCookieNameFor(httpx.SecureFromRequest(r))); 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, r *http.Request, 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: httpx.SecureFromRequest(r), // per-request scheme (D-043)
|
|
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: httpx.SecureFromRequest(r), // per-request scheme (D-043)
|
|
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
|
|
}
|