Skip to content

2FA slice 8: every Owner must use two-factor authentication #1281

Open

Description

@mforce

Important

Amended by #1274: see #1281 (comment). Needs: becomes #1277. The gate starts after a per-Owner grace period, reads the user's enrolment from the existing epoch check, lets the forced password change through and also applies to connected-app tokens. This slice ships no database migration and comes last.

Epic: #1273 · Size: M · Needs: #1278, #1280

Context

#320 notes that "Owner should be able to require a specific factor for privileged user-admin once >1 factor exists". The maintainer decided on this rule: every Owner must use two-factor authentication. For Managers it stays optional, and a farm setting that requires it for Managers is deferred.

An Owner holds farm configuration (#729) and user administration (#308), so a stolen Owner password is the most damaging single credential in a farm.

Scope

Acceptance criteria

  • An unenrolled Owner can reach only enrolment and logout. A test walks the middleware order next to MustChangePasswordMiddleware.
  • bootstrap-admin and provision-account produce an Owner who signs in, changes the generated password, enrols, and then reaches the app. A test covers the whole chain.
  • After recover-admin, the Owner signs in with the temporary password, changes it, and is sent to enrolment.
  • Promoting a user to Owner forces enrolment at their next sign-in. Demoting an Owner lifts the requirement.
  • A Manager, a Worker or a ReadOnly user signs in without enrolling.
  • The Playwright fixture and the k6 scripts that sign in as an Owner still work. The design record decides how the simulation Owner gets a known TOTP secret (Daily entry: require grading to reconcile sellable eggs before submit #394).
  • GLOSSARY, Help, en/es/tl strings, and screenshots of the forced enrolment at 1280 and 390.

Activity

  1. added
    sliceThin vertical work item
    area:apiAPI/endpoint layer
    priority:tier3Real product weight, real cost
    size:MA day or two; migration or a multi-state UI
    epic-1273Two-factor authentication (TOTP) (#1273)
    on Oct 12, 2026
  2. mforce commented on Oct 21, 2026

    @mforce
    OwnerAuthor

    The #1274 record (decisions 8, 14, 16, 18 and 22) settles these:

    • Order for a new Owner. The generated password, the forced change, this slice's gate, then enrolment. The gate never runs at the password step.
    • The gate sits after MustChangePasswordMiddleware in Program_PinsTheCompleteCredentialGateSequence. It allows change-password, enrolment, step-up, logout and the enrolment screen's reads, and skips the exception-handler re-execution. An unenrolled Owner past the deadline is refused with 403 Auth.TwoFactorEnrolmentRequired and no WWW-Authenticate challenge. The gate reads enrolment from the row through the epoch check, and the SPA routes to enrolment on the two_factor claim, which the coordinator agent chose. From the Owner's grace deadline, the gate refuses an unenrolled Owner's connected-app tokens, including tokens issued before this slice, and gates the consent route.
    • Grace period. I decided on 2026-10-21 that there is no hard cutover ("no hard cutover"), and approved a per-person grace counted from the Owner's first sign-in after this slice or from a promotion ("Yes, per-person buffer (Recommended)"). Each Owner gets TwoFactor:OwnerGraceDays days (default 14) from SecondFactorRequiredSince, which 2FA slice 3: tenant guard on AspNetUserTokens and TOTP secret storage #1276 adds. The agents moved the start to the Owner's first authenticated request after this slice, through the credential check that already runs on every request. That request sets the start once, enrolled or not, with a session or a connected-app token, and role changes and resets never move it. Until the deadline, the gate refuses nothing, connected-app tokens and consent included, and the Owner sees "Set up two-factor by " (the status route gains requiredBy). After a reset, an Owner past the deadline enrols again at the next request. Sign-in is never gated. After the deadline, the setup screen is where the app sends the Owner.
    • The deadline in the gate. CredentialEpochVerifier returns CredentialCheck(Verdict, TwoFactorEnabled, SecondFactorRequiredSince) from its existing read, and the middleware hands it to the gate, so the gate adds no query and covers tokens that never refresh. A missing check throws. A null start is set by IOwnerGraceStart.StartAsync, given the gate's own now, one guarded ExecuteUpdateAsync that also rotates ConcurrencyStamp, as SetLanguageAsync writes the row. If it fails with anything other than OperationCanceledException, which the gate rethrows, the request passes, the app logs Auth.OwnerGraceStartFailed, and the next request retries, as I decided on 2026-10-21 ("Log an alert event (Recommended)"). A start more than 5 minutes ahead of now counts as past the deadline. The test seed helpers set the start for seeded Owners, from the host's TimeProvider. The status route returns requiredBy to an Owner only. The gate and CredentialEpochMiddleware share one logout predicate.
    • Rollback. The grace keeps counting while a rolled-back image runs no gate, so the runbook says to raise OwnerGraceDays before redeploying.
    • The setting is a whole number from 1 to 365, validated at boot. Unset means 14, out of range fails the boot, and no value ever means enforce now. Above 14 it logs a "grace extended" warning at startup. If a bug blocks enrolment, the operator raises it and restarts.
    • The simulation Owner enrols through the API in reset.sh, after the password change, with no exemption flag. The key goes into .sim-cast.json. Playwright, the verify client, k6 and reset.sh compute codes, and wait for a fresh step after each accepted code or use distinct users. The Playwright /connect run, the consent-with-a-code case from 2FA slice 6: TOTP code as a step-up factor, including connected-app consent #1279, mutants.ts and the session specs land here (G6.5).
    • No exemption. No per-user setting, flag or seed exempts a user. The one global setting is the only exception. The gate reads only the role claim, the epoch check's two columns and that setting (G6.3). The simulation app service has no env_file: and does not set the days, so it cannot postpone enforcement (G6.8).
    • Test factors. This slice owns the record's test-factor policy (decision 22). Seeded users stay unenrolled by default, a choice the coordinator agent made, because an unenrolled Owner passes the gate during the grace period. Two-factor tests seed enrolled: true and get codes from their recovery codes or a controlled TimeProvider, which TotpMatcher also reads, with no wall-clock waits. One shared helper completes the second factor, and LoginAsync and LoginForAccessTokenAsync use it for an enrolled user. G6.6 runs a representative set of Owner endpoints with an enrolled Owner through that helper. CluckworkWebApplicationFactory raises RateLimiting:SecondFactor:PermitLimit, and a gate test past the deadline sets SecondFactorRequiredSince in the past directly.
    • After recover-admin. The forced change applies (2FA slice 7: turn off and reset TOTP, by the user, an Owner, or recover-admin #1280). The Owner keeps the original grace start, so the gate sends the Owner to enrolment only once that deadline has passed.
    • Data Protection ring. I decided on 2026-10-20 that previous-certificate support (Data Protection: keep reading keys protected by the previous certificate after a rotation #1338) does not need to ship before this slice ("No, don't wait (Recommended)"). After a certificate change, a user with enough unused recovery codes still recovers alone. Review round 12 later found that a signed-out user needs two codes, and a signed-in user needs one. An Owner with fewer uses recover-admin or another Owner.

    Acceptance criterion to add:

    • This slice ships no database migration, so it rolls back by image alone.

    Out of scope: an Owner requiring two-factor for other roles (#1336).

    Guards: G6.1 to G6.8 in the record.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layerarea:frontendReact/Vite web clientepic-1273Two-factor authentication (TOTP) (#1273)priority:tier3Real product weight, real costsize:MA day or two; migration or a multi-state UIsliceThin vertical work item

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions