Skip to content

docs(auth): record the two-factor authentication design - #1334

Open
mforce wants to merge 17 commits into
mainfrom
docs/1274-two-factor-design
Open

mforce wants to merge 17 commits into
mainfrom
docs/1274-two-factor-design

Conversation

@mforce

@mforce mforce commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

This PR adds the decision record for two-factor authentication (epic #1273). TOTP becomes both a login second factor and a step-up factor, and every Owner must enrol. The PR has no code. Slices #1276 to #1281 build from this record, and each one adds its rules to src/AGENTS.md when it lands.

Closes #1274

Where to start

The PR changes two files:

  • docs/decisions/1274-two-factor-authentication.md holds the whole design.
  • docs/decisions/README.md gains one index row.

Read the record from "The rule" through "The shape" first. Its sections then give the decisions, the existing tests the design touches, the guards each slice adds, and the glossary terms. The 15 commits are review rounds. Read the final file, not each commit, because the PR merges as one squash commit.

The design in short

  • Login has two steps for an enrolled user. A correct password mints nothing. It answers 401 Auth.SecondFactorRequired with a signed 5-minute challenge, and only POST /api/v1/auth/login/second-factor mints the session. Login for an unenrolled user is byte-identical to today.
  • One class owns all two-factor state. TwoFactorCredentials is the only code that checks an authenticator code or a recovery code, at login and at step-up. It locks the user row, so its checks are exact under concurrency.
  • Replay protection lives on the user row in Postgres, not in Redis. A per-user watermark stops a TOTP step from being used twice, and recovery codes are single-use rows.
  • Limits. A wrong code is charged to the same five-attempt lockout as a wrong password. For an enrolled user, no password check resets that counter.
  • Storage. The authenticator key is encrypted with the Data Protection has no persisted key ring, so Identity's token providers break across restarts and replicas #794 Data Protection ring. Recovery codes are 16 characters (about 75 bits), and each is stored as one row holding a SHA-256 digest of the user id and the code.
  • Recovery paths. A user can turn two-factor off, an Owner can reset another user's, and recover-admin clears it for an Owner. Each path that clears a factor ends the user's other sessions and connected apps.
  • The Owner rule. After a per-person grace period, a gate refuses an unenrolled Owner's tokens, connected-app tokens included, until the Owner enrols. Sign-in itself is never gated.

My decisions

Items 1 to 5 are from 2026-10-20, and items 6 to 12 are from 2026-10-21.

  1. An enrolled user steps up with the password and a code, one code per privileged action. An unlocked phone holds both the session and the authenticator app, so the code alone would let whoever holds the phone create an Owner or approve a connected app. I confirmed this again on 2026-10-21.
  2. Previous-certificate support for the Data Protection ring (Data Protection: keep reading keys protected by the previous certificate after a rotation #1338) does not need to ship before 2FA slice 8: every Owner must use two-factor authentication #1281. After a certificate change, a signed-out user needs two unused recovery codes to recover alone, one to sign in and one for the step-up that replacing the authenticator needs. A signed-in user needs one. A user with fewer needs an Owner's reset, and an Owner with fewer needs recover-admin or another Owner.
  3. recover-admin sets MustChangePassword = true. Today it clears the flag, so the temporary password it prints is never forced to change.
  4. A user gets 20 wrong authenticator codes a day. Twenty a day keeps a password holder's yearly chance of guessing the factor near 3.6%, against about 58% with lockout alone. On 2026-10-21 I raised the code step's per-address default from 10 to 20 requests per 15 minutes, still configurable as RateLimiting:SecondFactor:PermitLimit.
  5. The login challenge is single-use. Once a challenge completes a sign-in, it can never complete another. The challenge carries the user's count of completed sign-ins, and the code step refuses it once that count has moved. The rule reads no clock (decision 2).
  6. 2FA slice 8: every Owner must use two-factor authentication #1281 has no hard cutover, and the grace is per person. Each Owner without two-factor gets TwoFactor:OwnerGraceDays days, 14 by default, counted from that Owner's first token after 2FA slice 8: every Owner must use two-factor authentication #1281, a connected app's token included, or from a promotion to Owner. Until then the Owner keeps full access and sees "Set up two-factor by ". If a bug blocks enrolment, the operator raises the days and restarts, which moves every deadline out. 2FA slice 8: every Owner must use two-factor authentication #1281 ships no database migration, so rolling back the image always works.
  7. An enrolled user's session needs a full sign-in, with the password and a code, every 30 days (Auth:EnrolledSessionMaxDays), even while it is in use. Unenrolled sessions and connected apps keep today's behaviour.
  8. The authenticator app shows "Cluckwork ()" with the user's email, and each farm's user enrols separately.
  9. Enrolment still signs out other devices and connected apps, and the setup screen warns before confirm.
  10. The Account page warns from 2 recovery codes left and offers Regenerate.
  11. After a wrong code, the message adds "Still not working? Check that your phone sets its time automatically."
  12. Deferred to their own issues: two-factor for other roles (Two-factor: let an Owner require it for other roles #1336), a notice when two-factor changes, beyond the audit row (Two-factor: tell a user at sign-in when someone else changed or bypassed their two-factor #1337), and a forced reconnect for connected apps (Connected apps: configurable approval lifetime that forces reconnect #1335).

Slice order

The record changes the epic's original order to #1276, #1278, #1279, #1280, #1277, #1281. #1277, the slice that lets users enrol, now comes after login, step-up enforcement and every recovery path. No deployable build on main then lets a user enrol while step-up enforcement or a way back is missing. #1278 builds the enrolment methods without routes, so its tests enrol users through the one real writer. I still need to approve this order.

Existing tests

The record's "Existing tests the design touches" section lists each test group by grep.

Review

I ran 11 local review rounds before the first push. Rounds 1 to 5 read the whole record. Rounds 6 to 11 read only the change since the round before. The reviewers were Claude Opus 5.5 (A), Claude Fable 5.1 (B), Claude Sonnet 5.5 (C), Codex gpt-6-astra (D) and GLM-5.3-Flash on my homelab vLLM (E). The syntheses, reviews and deltas are in /home/mforce/.cluckwork-slices/totp/interrogate-1274/.

Two narrow checks (rounds 12 and 13) read 571d680..e5edc56; round 12 found that self-service recovery needs two codes when signed out, round 13 is clean.

"Raw" counts each reviewer's findings separately, and only rounds 7 to 11 reported that number. "Synthesized" counts the deduplicated items that the synthesis asked me to act on, plus its cheap fixes.

Round Scope Reviewers Raw Synthesized What it found, and how I answered
1 whole record A, B, C, D, E not reported 18, plus 6 to decide Per-user limits sat on a shared counter that falls back to per-process. I moved them to a durable count on the locked user row and put all state in one owner class.
2 whole record A, B, C, D, E not reported 11, plus 6 cheap I fixed all of them and stated the challenge as a credential.
3 whole record A, B, C, D, E not reported 9, plus 6 cheap How to tie the Owner gate to enrolment. I chose a two_factor token claim and reordered the slices.
4 whole record A, B, C, D not reported 10, plus 7 cheap Nothing critical. I settled the slice order above and placed each guard in its slice.
5 whole record A, B, C, D, E not reported 7, plus 4 cheap One critical: a rewrite had dropped IgnoresAmbientPrincipalAttribute from the code step, which reopened the #532 lockout bypass. I restored it with a guard modelled on the #532 test, and gave ClearAsync a three-state result.
6 delta A, B, C, D not reported 5, plus 5 cheap Nothing critical. I defined the no-op paths, restored the no-exemption rule, and wrote each open question so it stands alone.
7 delta B, D 9 4 Mostly the #1281 test harness. I replaced the harness plan with a test-factor policy and recorded my four answers.
8 delta B, D 8 5 The test clock against the JWT lifetime, helpers named by list, and two weak mutations. I fixed each and added the single-use challenge.
9 delta B, D 7 5 All came from a single-use floor based on time. I replaced it with a clock-free sign-in count.
10 delta B, D 3 3 Small items. I allowed the password step's read of the count and fixed one mutation.
11 delta D 0 0 Clean, nothing above a nit.

Guards

Both guards walk tracked files, and I ran them at the head of this branch:

dotnet test tests/Cluckwork.Api.IntegrationTests --no-build --filter "FullyQualifiedName~ImagePin_IsOneIdenticalString"
Passed!  - Failed:     0, Passed:     2, Skipped:     0, Total:     2

dotnet test tests/Cluckwork.Application.Tests --no-build --filter "FullyQualifiedName~Documentation"
Passed!  - Failed:     0, Passed:    20, Skipped:     0, Total:    20

mforce added 17 commits October 20, 2026 16:22
Decides #1274 items 1 to 23: the login state machine with a signed challenge and a per-user watermark, step-up with the password and a code, key encryption with the #794 ring, recovery-code digests, recovery and administration, audit and security events, the QR library and the login callers. Cites Identity 10.0.13 source and two probes.
… attempt limit

Answers a review of the decision trail: enrolment confirm now has its own verifier operation, an Owner can replace an authenticator with a recovery code, a reused code is not charged, the per-user attempt limit is atomic, two guard mutations reach the check they claim, and the DML-only test is a precedent to extend, not a proof.
One class, TwoFactorCredentials, owns all two-factor state and locks the user row, so lockout and a durable daily budget of wrong authenticator codes are exact and survive restarts. The challenge is no longer single-use on its own, recovery codes are one row each and stay outside the budget, the step-up counter reset is skipped for enrolled users, the #1281 gate reads live state and lets the forced password change through, and guards move to the slice that builds the code they guard.
TwoFactorCredentials takes ids, locks the user row without loading an entity and reloads it, and writes AspNetUsers only through UserManager saves. An accepted authenticator code no longer clears the daily budget, wrong enrolment confirms are capped at five, the #1281 gate does its own read and refuses with 403, and the guards name mutations that can turn them red. Net smaller than the previous head.
One commit rule: the caller always commits what TwoFactorCredentials wrote, callers save before they call it, and reads never lock. Confirm returns its recovery codes, step-up opens its transaction after the password check and answers 503 on a commit failure, #1278 lands before #1277 so no build lets a user enrol into a factor login ignores, the #1281 gate reads a two_factor claim, and the guards name mutations that can turn them red. Net smaller than the previous head.
Settles the build order as #1276, #1278, #1279, #1280, #1277, #1281, so the enrolment routes are mapped only after step-up enforcement and every recovery path exist, and re-places the guards to match. ClearAsync now owns its epoch bump and returns whether it cleared a factor, confirm has typed outcomes and a status table, the commit rule names its dependence on staying outside IdempotentUnit, and the recovery-code digest format is pinned. Net smaller than the previous head.
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.

2FA slice 1: design record for TOTP at login and as a step-up factor

1 participant