Skip to content

feat(auth): give AspNetUserTokens a tenant guard and store two-factor secrets protected - #1345

Open
mforce wants to merge 15 commits into
mainfrom
feat/1276-two-factor-storage
Open

mforce wants to merge 15 commits into
mainfrom
feat/1276-two-factor-storage

Conversation

@mforce

@mforce mforce commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Summary

This is the first two-factor slice of #1273. It builds the storage that the later slices use, and it changes nothing a user sees: no route, no screen, and no enrolment path yet. The design is the #1274 record ("The state it owns", decisions 9, 13, 15 and 22).

 AspNetUserTokens
+  AccountId uuid NOT NULL          # shadow property, backfilled from the user, no default
+  FK (UserId, AccountId) -> AspNetUsers(Id, AccountId) ON DELETE CASCADE
 AspNetUsers
+  SecondFactorWatermark      bigint  NOT NULL DEFAULT 0
+  SecondFactorSignIns        bigint  NOT NULL DEFAULT 0
+  SecondFactorFailures       integer NOT NULL DEFAULT 0
+  SecondFactorFailuresSince  timestamptz NULL
+  SecondFactorRequiredSince  timestamptz NULL
 refresh_tokens
+  SignedInAt                 timestamptz NULL

AspNetUserTokens gets the same tenant guard that #670 gave AspNetUserRoles. The interceptor stamps and checks the shadow AccountId, the #562 walk makes it a concurrency token, and the composite foreign key refuses a row for another farm's user. One migration, 20261021205522_AddTwoFactorStorage, adds every two-factor column, so #1278 to #1281 ship no migration.

TwoFactorCredentials (internal; #1278 adds the methods that call it)
  StageProtectedAsync(userId, slot, plaintext)   # account from the resolved tenant; throws under none
  ReadProtectedAsync(accountId, userId, slot)    # takes the account: login reads before the tenant resolves
  StageRecoveryCodes(userId, codes)              # one "RecoveryCode:<sha256(userId:code)>" row per code, no value
  RecoveryCodeName(userId, code)                 # the one digest function; normalizes, fails closed on length
  Protector = CreateProtector("Cluckwork.TwoFactorCredentials", accountId, userId)
  every row: LoginProvider = "Cluckwork"

The live authenticator key and the pending payload are protected with the #794 key ring. The protector's purpose names the account and the user, so a value copied to another user's row does not unprotect. Recovery codes are stored only as SHA-256 digests of the user id and the code. Identity's own [AspNetUserStore] rows are never written, and ProtectPersonalData stays off.

Where to read:

  • Core: TwoFactorCredentials.cs, the AspNetUserTokens mapping in AppDbContext.cs, the hand-written backfill and DROP DEFAULT in 20261021205522_AddTwoFactorStorage.cs, and the new properties on ApplicationUser and RefreshToken.
  • Guards: UserTokenTenantWriteTests, UserTokenAccountIdModelTests, UserTokenAccountIdMigrationTests, TwoFactorCredentialsStorageTests, and the two-factor ban in GlobalUserLookupGuardTests.
  • Generated, skim only: the migration's .Designer.cs, AppDbContextModelSnapshot.cs and docs/schema/, regenerated with tools/schema-docs/generate.sh.
  • Docs: 530 §5 and its enforcement list (three Identity tables remain the accepted risk, not four), the 794 runbook, two src/AGENTS.md lines, and the record's "How it is enforced" sentence.

Acceptance criteria

From the issue, with the amendment comment's three items in place of the two "If #1274 chooses…" items:

  • A write under another farm's tenant is refused by the interceptor and by the foreign key. UserTokenTenantWriteTests copies the six UserRoleTenantWriteTests cases. A detached add for another farm's user is refused by the foreign key, and a detached remove by the interceptor. A forged remove is refused by the database. Tracked relabel and tracked remove are refused by the interceptor.
  • A write under an unresolved tenant is refused. A raw add is refused by the foreign key. Both stage methods throw before they track a row, and nothing is stored.
  • A protected value is not the plaintext key, reads back after a restart and from a second instance, and does not unprotect when copied to another user's row. TwoFactorCredentialsStorageTests covers both protected rows. The purpose chain is pinned directly: another account, the order swapped, or no account all fail.
  • UserName and Email stay plaintext. A raw-SQL read of the user row checks them.
  • No stored row holds a plaintext recovery code. Each of ten rows is a 64-hex digest name with no value, and no row name contains a code. A fixed test vector pins the digest construction.
  • TableOwnerRealModelTests, TenantBypassDiscoveryTests and the schema-docs check pass. The first two keep their expected sets.
    • FilterFreeSetSites gains two classified rows, one for the query root and one for the insert. The query root filters by UserId and AccountId.
    • The AccountIdConcurrencyTokenModelTests carrier floor rises from 29 to 31, the measured count, and the test now also requires IdentityUserToken<Guid>.
  • A mutation that removes the foreign key or the shadow column turns a test red. See Evidence.
  • Amendment items.
    • The protect site, the "Cluckwork" rows, the five user columns with their defaults, and refresh_tokens.SignedInAt all ship here.
    • GlobalUserLookupGuardTests bans, by name, UserManager's authenticator, recovery-code and authentication-token methods and the recovery-code store's ReplaceCodesAsync, RedeemCodeAsync and CountCodesAsync, anywhere in src/ except build output and migration-shaped files. It does not ban the token store's GetTokenAsync, SetTokenAsync or RemoveTokenAsync, because HttpContext.GetTokenAsync shares the name. Coverage by interface is 2FA slice 5: TOTP code as a second step at login #1278's G3.20.
    • The 794 runbook gives the recovery rule after a certificate change or a ring deletion. A signed-out user needs two unused recovery codes, a signed-in user needs one, and anyone with fewer needs an Owner or recover-admin.

Not in this slice, as decision 22 says: ITwoFactorCredentials, its DI registration, code generation, verification and every route (#1278 onward). That nothing but TwoFactorCredentials writes the token rows or the SecondFactor* columns stays review-only until #1278's guard G3.20.

Evidence

Migration on a real Postgres. I ran 20261021205522_AddTwoFactorStorage on a throwaway container of the repo's pinned postgres:18.4-trixie image. Before the migration ran, the database held three AspNetUserTokens rows from two farms. This is from the full log:

Applying migration '20261021205522_AddTwoFactorStorage'.

  Name   |            token_account             |             user_account             | matches
 ProofA1 | 0000000a-0000-0000-0000-000000000001 | 0000000a-0000-0000-0000-000000000001 | t
 ProofA2 | 0000000a-0000-0000-0000-000000000001 | 0000000a-0000-0000-0000-000000000001 | t
 ProofB1 | 0000000b-0000-0000-0000-000000001276 | 0000000b-0000-0000-0000-000000001276 | t

ERROR:  insert or update on table "AspNetUserTokens" violates foreign key constraint "FK_AspNetUserTokens_AspNetUsers_UserId_AccountId"
DETAIL:  Key (UserId, AccountId)=(0000000a-…a276, 0000000b-…1276) is not present in table "AspNetUsers".
ERROR:  null value in column "AccountId" of relation "AspNetUserTokens" violates not-null constraint

 AspNetUsers      | SecondFactorFailures      | integer     | NO  | 0
 AspNetUsers      | SecondFactorSignIns       | bigint      | NO  | 0
 AspNetUsers      | SecondFactorWatermark     | bigint      | NO  | 0
 AspNetUsers      | SecondFactorFailuresSince | timestamptz | YES |
 AspNetUsers      | SecondFactorRequiredSince | timestamptz | YES |
 refresh_tokens   | SignedInAt                | timestamptz | YES |

Reverting migration '20261021205522_AddTwoFactorStorage'.
  (four queries, summarized: columns left 0, FK left 0, index left 0, token rows kept 3)
Applying migration '20261021205522_AddTwoFactorStorage'.
  rows_not_backfilled: 0

Down keeps AK_AspNetUsers_Id_AccountId, because the #670 migration owns it.

Mutations. Before I claimed any guard, I broke the code it protects and watched it go red. Every valid mutation went red on the test it targets, except the two greens explained below the table.

The per-round result files are in my local working directory, not in this repository, so the table lists what each round mutated.

Round Mutations
First build FK dropped in the database and everywhere; shadow column dropped; default kept; backfill dropped; SecondFactorFailures made nullable with no default (one mutation changing both); no protector; user dropped from the purpose; plaintext code in the row name; user dropped from the digest; account left to the interceptor (superseded: round 1 removed the hand-set AccountId it guarded); ProtectPersonalData on with a working protector
Review 1 account dropped from the purpose; purpose edited or swapped; row name renamed; hand-set AccountId; normalization broken; digest separator changed
Review 2 a compiling call of each of the 12 banned methods; stage under a wrong account; no tenant check; no length check
Review 3 empty and shallow walk; scanner ignores its list; tenant check removed from StageRecoveryCodes; names computed while tracking; a banned call in AdminRecoveryService.cs
Review 4 empty and shallow walk; one banned name deleted; walk passes the wrong list; snapshot no longer skipped
Review 5 TwoFactorCredentials.cs allowed, skipped by name, or inside a skipped subtree; Cluckwork.Api skipped

Two greens, and the invalid attempts:

  • Round 4: reading obj/ again stayed green, and I accepted it without a guard. Today the scans read 639 and 637 files after their skips, against a floor of 550, so reading the generated files under obj/ again cannot change a result. No test guards the build-output skip.
  • Dropping the account from the purpose went green at first. User ids are globally unique, so a copied row cannot isolate the account. After the purpose chain was pinned directly, the same mutation went red.
  • Five first attempts were invalid. Two failed the host on EF's pending-model check, two broke the build, and one crashed at model build instead of reaching the guard. Each was re-run as a valid mutation, and my local results mark the invalid ones.

Tests at 8bd45974, the merge with main.

  • Build. dotnet build Cluckwork.slnx gives 0 warnings and 0 errors.
  • Application tests. 175 targeted Application tests pass: tenant bypass, table owners, module ledger, file size, spelling scanner, tenancy docs, rename-account docs and the coupling matrix.
  • Integration tests. 105 targeted integration tests pass on Testcontainers Postgres. They cover the token and role tenant tests, the storage tests, the key ring, schema docs (including both ImagePin guards), GlobalUserLookupGuardTests, the raw-SQL user fixtures, migration security and the concurrency-token model.
  • Schema docs. I reran tools/schema-docs/generate.sh --check at 8bd45974 on 2026-10-22 at 03:04 UTC. It exited 0 and printed docs/schema/ is up to date.

Review. The automated per-commit security review ran on each of this branch's commits that changed source files and reported no security issues, except one review at 21:19 UTC on 2026-10-21 that got no verdict, because the reviewer's credentials were rejected (HTTP 401). The sweep over the branch at push time, at 02:52 UTC on 2026-10-22, reported no new findings. Separately, a five-model local review (Opus, Fable, Sonnet, Codex and GLM) covered security over six rounds. No round found a critical issue, and round 6 found nothing above a nit.

I deferred round 6's nits to #1278, which edits the same file. They are test cleanup in GlobalUserLookupGuardTests.cs. The main one: no test pins that the scanned-file list excludes skipped files, or that a file is recorded only after it is read. The others fix a "same code path" comment, narrow two comments and the sentinel's message, return the scanned list from ScanFiles instead of an out-list, and make the sentinel's failure print the list.

Merge Danger

Door: two-way

Down reverts the migration cleanly, and I ran it above. Rolling back by image alone is also safe. The old code writes no AspNetUserTokens row, the new AspNetUsers columns have defaults, and SignedInAt is nullable, so inserts from the old image keep working.

Blast Radius: Identity storage

The migration takes locks on AspNetUsers, AspNetUserTokens and refresh_tokens while it adds columns, and on AspNetUserTokens while it backfills. Nothing in src/ wrote AspNetUserTokens before this slice, so the backfill should touch few or no rows. Nothing reads the new columns until #1278.

Closes #1276

mforce added 15 commits October 21, 2026 21:04
…tor columns

AspNetUserTokens gets #670's seam: a shadow AccountId, stamped and checked by TenantStampInterceptor and tokened by the #562 walk, and a composite foreign key to AspNetUsers(Id, AccountId) with cascade. One migration backfills it and drops the add-column default. The same migration adds the five AspNetUsers two-factor columns and the nullable refresh_tokens.SignedInAt, so later slices ship no migration (#1274 decisions 8 and 15).

Refs #1276
…as digests

TwoFactorCredentials is the one protect site: the live key and the pending payload are protected with one purpose naming the account and the user, in Cluckwork token rows. Each recovery code is one row named by the SHA-256 digest of the user id and the code, with no value. Rows carry the caller's account, so the composite foreign key checks it before the tenant resolves. Both db.UserTokens sites are classified rows that filter by UserId and AccountId (#1274 decisions 9 and 13).

Refs #1276
…or storage rules

530 section 5 and its enforcement list now name three Identity tables as the accepted risk, not four, and describe the AspNetUserTokens seam. src/AGENTS.md gains the two-factor storage rule. The 794 runbook says how many recovery codes a user needs after a certificate change or a ring deletion.

Refs #1276
…comments

The Rows query's FilterFreeSetSites signature is re-pinned because its tokens changed. The copy test's comment no longer claims the cross-farm case isolates the account: user ids are globally unique, so the account in the purpose has no observable effect.

Refs #1276
…stored names

AddRow no longer sets AccountId, so the interceptor stamps every add from the resolved tenant and the foreign key refuses an add under no tenant (#1274 decision 15). The row names and the protector purpose are constants with literal tests, and the purpose chain is pinned directly. RecoveryCodeName normalizes the code itself and is pinned by a fixed vector. The flaky four-character fragment check is gone.

Refs #1276
…API anywhere in src

GlobalUserLookupGuardTests bans nine UserManager methods with no allowed file, so the src/AGENTS.md rule has a guard. 530 and src/AGENTS.md now say that adds are stamped by the interceptor, and that the column-writer rule waits for #1278's G3.20.

Refs #1276
The carrier floor rises from 29 to 31, the measured count, and the walk must find IdentityUserToken<Guid> by name, as it finds IdentityUserRole<Guid>.

Refs #1276
The stage methods take the account from TenantContext and throw when none is resolved, so a value can no longer be protected under one account and stamped with another. ReadProtectedAsync keeps its account argument, because login reads before the tenant resolves. The two protected rows become a closed class that states each name once, the stored constants are private, and the digest's length check has its own test.

Refs #1276
…ctor ban its own test

The two-factor list adds ReplaceCodesAsync, RedeemCodeAsync and CountCodesAsync and runs as its own test with its own failure message and no allowed file. 530 and src/AGENTS.md say exactly what the list covers, that EF-generated migration files are skipped, and that an update of a stored value goes through the same account filter.

Refs #1276
…proof

The walk must read TwoFactorCredentials.cs and at least 639 files, the source count measured without obj/, so an empty walk fails. A fixture theory proves each banned token is reported in a source file and skipped in a migration-shaped one. The failure message names the migration exemption.

Refs #1276
StageRecoveryCodes checks the tenant and computes every row name before it tracks a row. The tests say why the row names stay literals, the read-back test's comment claims only what it proves, and the foreign-key helper is private again.

Refs #1276
… the banned list

Both walks now enumerate through one SourceFiles helper that skips bin/ and obj/, requires TwoFactorCredentials.cs, and holds a floor of 550 against the 694 source files measured on 2026-10-22. The real walk and the fixtures share one two-factor scan call, the theory's names are literals with an equality check against the list, and a model-snapshot probe pins the second migration shape. The docs say migration-shaped files are skipped.

Refs #1276
A missing tenant or a malformed code throws before any row is tracked. A duplicate code throws on its second add, so the #1278 generator must return distinct codes.

Refs #1276
…ly read

ScanFiles reports the files it read after its allowed-file and migration skips, and both walks assert on that set: it must include TwoFactorCredentials.cs and hold at least 550 files (639 and 637 measured). A scanner skip of that file or of a subtree now fails. The skip wording names build output as well as migration-shaped files, the sentinel's message says what a move means, and TwoFactorApi says what pins it.

Refs #1276
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 3: tenant guard on AspNetUserTokens and TOTP secret storage

1 participant