Skip to content

Central auth: TOTP MFA with recovery codes + audit-viewing surface - #36

Open
swimmesberger wants to merge 13 commits into
mainfrom
wt/watchtower-open-issues-432839
Open

Central auth: TOTP MFA with recovery codes + audit-viewing surface#36
swimmesberger wants to merge 13 commits into
mainfrom
wt/watchtower-open-issues-432839

Conversation

@swimmesberger

Copy link
Copy Markdown
Owner

Part of #18 (§4 TOTP, audit-view follow-up) and #31 (R7 MFA baseline, R8 audit surface) — the two pieces shared by both issues.

TOTP MFA (feat/auth-totp)

  • Identity-core mechanics, no new backend dependency: WatchtowerUserStore implements the 2FA/authenticator-key/recovery-code store interfaces; validation is Identity's built-in RFC 6238 provider.
  • Schema: users.two_factor_enabled + users.authenticator_key, new user_recovery_codes table — 10 codes per issue, SHA-256-hashed at rest, delete-on-redeem (single-use under concurrency).
  • Login challenge: a correct password on a 2FA account returns { mfaRequired, mfaToken } (single-use pending record, 5-min lifetime, hashed at rest, excluded from every session-validation path by an allow-list) — POST /api/auth/login/mfa finishes with a TOTP or recovery code. Wrong codes count toward the account lockout; the per-IP login limiter covers the route; the pending token is realm-host-bound.
  • Self-service under /api/auth/mfa/* (status / begin / confirm / disable / regenerate) — deliberately REST rather than RPC so accounts of any realm can protect themselves (RPC handlers are system-realm-gated). Enrollment confirm requires the account password (step-up), all judging endpoints are rate-limited and drive the lockout.
  • Admin: users.resetMfa handler + 2FA column and Reset two-factor action on the Users page.
  • Break-glass (RESETPASSWORD) now also clears the second factor — recovery stays a way in for an admin who lost both factors.
  • Frontend: MFA step on the login page (with recovery-code fallback), new account-security page with QR enrollment (qrcode dep), recovery-code sheet gated on explicit acknowledgement.
  • 7 new audit kinds (mfa.totp.*, mfa.recovery.*, login.mfa.*).

Audit view (feat/auth-audit-view)

  • New read-only Audit module: audit.list (keyset pagination on Id DESC — stable under concurrent appends and sidesteps the SQLite ORDER BY DateTimeOffset limitation; kind/user/route filters; limit clamped 100/500) and audit.kinds (distinct values, so new kinds — including this PR's MFA kinds — become filterable with no frontend change).
  • Null-safe projection (SET NULL FKs: the trail outlives its subjects), no secrets in the DTO.
  • New admin-gated Audit page: kind badges, filters, load-more paging, mobile cards.
  • Docs updated: the "no audit UI" limitation is replaced by the honest remaining one (no retention/export).

Process & verification

Each feature was implemented in an isolated worktree, went through an independent review loop (TOTP: 1 blocker — break-glass couldn't recover a 2FA'd admin — plus 3 majors, all fixed and re-verified; audit view: 1 minor + 3 nits, fixed), and a final architecture gate before merging.

  • dotnet build: 0 warnings, 0 errors
  • Tests: 198 (Api) + 659 (Application), 0 failures — includes new store, pending-record, endpoint, lockout/rate-limit, break-glass and audit-module coverage
  • Frontend: typecheck + production build clean; RPC schema regenerated (108 methods)
  • MFA flows additionally verified live against a running auth-enabled instance (enroll → challenge → recovery-code login → admin reset)

Deliberate deferrals (tracked)

Two [RequireRole("Admin")] readers over the rows every other module already
writes: audit.list (keyset paging on the primary key, plus kind/user/route
filters) and audit.kinds (the distinct kinds present, so the filter offers what
is there rather than what a frontend constant remembers).

Paging is a cursor rather than an offset because the trail is append-only and
written while it is read; ordering is by id rather than CreatedAt because the
SQLite provider cannot ORDER BY a DateTimeOffset. Both optional references are
projected null-safely: the FKs are SET NULL, so a row about a deleted account is
the normal end state of a long-lived trail.

No writers, no migration, no retention.
Extends WatchtowerUserStore with the three Identity two-factor stores, adds the
User.TwoFactorEnabled/AuthenticatorKey columns and a user_recovery_codes table
whose rows hold SHA-256 hashes (AuthSessionService.HashToken) and are deleted on
redemption, so a code is single-use by the delete's affected-row count.

AuthSessionService gains the pending-MFA record (SessionKind.MfaPending, 5-minute
lifetime, hashed at rest) and ValidateAnyAsync now excludes that kind explicitly —
the other two validate paths already match on Sso/App, so a half-finished login can
never become an identity.

UserMfaService is the single implementation of enrol/confirm/verify/disable/reissue
that the login endpoint, the self-service endpoints and the admin reset all drive;
Identity's own RFC 6238 AuthenticatorTokenProvider does the code validation, so no
TOTP package is added.
New admin-gated `audit` module, sidebar order 57 — between Groups and Settings,
because the trail is the record of what was done to the entries above it.

Read-only: no mutations at all. Filters (event kind, user, app) are part of the
query key, so changing one starts a fresh first page; "Load more" follows the
server's keyset cursor and Refresh drops the loaded pages and re-reads from the
newest row. The kind dropdown is fed by audit.kinds rather than a frontend
constant, so a kind a future writer introduces becomes filterable with no edit
here. The user and app pickers are gated on their modules being enabled.
…etMfa

POST /api/auth/login stops short of the SSO session for a two-factor account and
answers { mfaRequired, mfaToken } — a pending record in the body, never a cookie.
POST /api/auth/login/mfa finishes the login from a TOTP or a recovery code, with
the same response and hand-over semantics as a single-factor login; a wrong code
keeps the challenge but counts against the lockout, and the login rate limiter
covers the route.

The self-service surface (/api/auth/mfa/*) is REST rather than RPC because every
management handler is gated to the operator realm and protecting your own account
is not management. Enrolment is refused while two-factor is already on: minting a
new key for an enabled account would lock its owner out without ever asking them
for anything.

users.resetMfa is the administrator's one-directional counterpart — it can remove
a second factor, never add one.
The 'No audit-viewing UI' known limitation is retired; what remains true of it —
no retention, no export, no deletion — is stated in its place. design.md §7 and
§8 gain the Audit module and page, with the two reasons the paging looks the way
it does: an append-only table being written while it is read (cursor, not
offset), and SQLite refusing to ORDER BY a DateTimeOffset (Id, not CreatedAt).
…for MFA

TotpCodes is an independent RFC 6238 implementation (Identity's is internal), which
is what makes the verification tests meaningful: a code produced by the verifier's
own arithmetic would agree with it however wrong both were. Linked into the API test
project rather than duplicated.

MfaPendingSessionTests pins the invariant the design rests on — a pending token is
refused by ValidateAsync, ValidateAppSessionAsync and ValidateAnyAsync alike — plus
expiry, single-use consumption and the disabled-account case.

MfaEndpointTests drives the real pipeline: enrolment through the shipped endpoints,
the challenge answering without a cookie, the pending token failing as an RPC
credential, wrong codes counting against the same five-attempt lockout the password
uses, recovery-code login, disable and regenerate, and the audit row for every new
kind.
LoginPage gains a code step when the password answers with a challenge, with a
recovery-code toggle. The expired-challenge case is decided client-side: the backend
answers a wrong code and a lapsed one identically (saying which would tell a caller
holding a stolen password whether the window is worth grinding), but this client
knows how long ago it asked, so it can offer the right recovery.

SecurityPage is platform rather than a feature module because every account may
protect its own credentials — including one outside the operator realm, for whom the
shell renders the applications portal instead of routes. The shell therefore lets
exactly this path through, and the portal grows a link to it. Enrolment is a QR code
(qrcode, code-split into the page's own chunk) with a manual-key fallback, then
confirmation, then a recovery-code sheet whose Done button is gated on an explicit
acknowledgement — the codes cannot be shown again.
- Sidebar order 58, not 57: Realms already claims 57, and equal orders fall back
  to an alphabetical tie-break, which would have slotted Audit between Groups and
  Realms and split the three screens meant to be read together.
- Refresh invalidates the kinds dropdown too — it is derived from the same rows,
  so a newly recorded kind would otherwise stay unfilterable until a reload.
- Refresh no longer spins during the initial load; the skeleton rows already
  report that, and the foot button reports "load more".
- ArgumentNullException.ThrowIfNull(query) in ListAuthEventKinds, matching
  ListAuthEvents.
Review blocker: WATCHTOWER__AUTH__RESETPASSWORD now disables 2FA, clears the
authenticator key and deletes the recovery codes, and says so in both the audit
detail and the warning log. The commonest reason to reach for this hook is a lost
authenticator, so a recovery that restored only the password restored nothing —
and whoever can set the variable and restart already owns the deployment, so the
factor was never a barrier to them.

The self-service routes now carry the login rate limiter and drive the account
lockout on a refused code: holding a session is not a reason to relax either,
since a borrowed session is exactly the case where someone grinds six digits to
turn the factor off. The status read stays unlimited — it judges nothing.

Confirming enrolment additionally requires the account password. The code proves
possession of the new authenticator, which whoever holds a borrowed session also
has; the password is the one thing that session does not carry.

Also: login/mfa binds the challenge to the realm of the host it arrives on;
ValidateAnyAsync filters by allow-list so a new SessionKind cannot drift in;
DisableAsync is one user write inside a transaction; ConfirmTotpAsync rolls the
flag back rather than leaving an account demanding a factor with no way around it;
the password step no longer clears the failure budget before the 2FA branch; and
every claim that stamp rotation invalidates a session is corrected — nothing reads
the stamp yet, it is bookkeeping for a later hook.
The setup dialog's confirm step now asks for the account password alongside the
code, matching the backend: the code proves the new authenticator works, which is
something whoever borrowed a signed-in browser also has.

The recovery-code sheet can no longer be dismissed by Escape or an outside click
until the acknowledgement is ticked — the codes cannot be shown again, so a stray
key press there costs every code just issued. The login page grows the missing
branch for a non-'signed-in' outcome rather than leaving the button spinning.

UserDto carries TwoFactorEnabled (a policy fact, not a secret), the Users page
shows a 2FA column and offers Reset two-factor behind a confirm dialog, and the
row action appears only where there is something to clear.

Docs: README, the central-auth operator guide and design.md now describe the
shipped surface — including that break-glass clears the second factor — and mark
per-realm enforcement as the part still outstanding.
Reconciles the audit-view merge: both README feature bullets kept,
securityRoute + audit.routes both registered, rpc-schema regenerated
on the combination (108 methods, byte-identical to the auto-merge).
Also removes an orphaned JSDoc block in AppsPage.tsx (review nit #14).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant