From 55d6d1e025fade225f2521506b7fcc438d652b0c Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 13 Oct 2026 13:01:50 +0200 Subject: [PATCH] feat(sign-in): the e-mail code stands in for a second factor that is gone An administrator whose only second factor was a passkey on a broken device could not sign in: the login asked for that passkey and offered nothing else, and the e-mail code could not be switched on because that is an account change, which needs the same passkey. The way back in was the recovery CLI, which a realm administrator cannot reach. ADR 0025, amendment 3: - After a password, the e-mail code is offered beside the user's own second factor (TOTP or a usable passkey) wherever the target offers the e-mail code after a password. No opt-in; a confirmed address is required. Browser only: no native grant starts with a password. - It stands in for a factor the user has. A user without any second factor still gets the setup duty, and the fallback never raises the level. - A session signed in with the password and a fresh e-mail code may change what protects the account (remove the passkey, set up TOTP). The step-up for an account change offers the code. - For EmailFallbackWaitHours (realm setting, default 24, 0-720) after a password reset by e-mail the fallback is neither offered nor accepted: password and code would come from the same mailbox. UserSecurityData.PasswordResetByEmailAt records the reset. - A realm that does not offer the e-mail code after a password has no fallback. This deliberately reverses the earlier hardening that issued the e-mail second factor only to users who had switched it on (audit M2). The opt-in still gates the code for a user without another factor. Also: - A password reset by e-mail now sends the account-change notice; only the change in the profile did. - /api/account/email-otp/login accepts a code only where one may be issued as this user's second factor. A code issued by the e-mail-code sign-in shares the challenge and was accepted there after a password. Tests: unit tests for the rule and the wait; integration scenarios S15-S17 (sign in and remove the passkey, the wait after a reset including a code from the sign-in path, confirming an account change from a password session) and the realm switch. Co-Authored-By: Claude Opus 5.5 --- docs/admin/realm-settings.md | 5 +- docs/concepts/sign-in-levels.md | 11 + ...assurance-is-required-by-the-target-app.md | 38 +++ docs/end-user/first-steps.md | 2 +- docs/end-user/passkey.md | 2 +- docs/end-user/two-factor.md | 2 +- docs/operate/recovery-cli.md | 8 +- .../EmailFallbackSecondFactorTests.cs | 220 ++++++++++++++++++ .../Provisioning/RealmManifestExporter.cs | 1 + .../DTOs/RealmSettings/SignInPolicyDtos.cs | 2 + .../Api/Account/AccountChangeGuard.cs | 9 + .../Api/Account/EmailOtpEndpoints.cs | 29 ++- .../Api/Account/PasswordResetEndpoints.cs | 16 +- .../Domain/UserSecurityData.cs | 5 + .../Identity/EmailOtpService.cs | 16 ++ .../Identity/IEmailOtpService.cs | 7 + .../RealmSettings/RealmSettingsService.cs | 3 + .../RealmSettings/SignInPolicyRules.cs | 5 + .../SignIn/SignInRequirementService.cs | 40 +++- .../Applications/EffectiveSettings.cs | 1 + .../Modgud.Domain/Realms/SignInPolicy.cs | 6 + .../SignInRequirementEvaluationTests.cs | 72 +++++- src/frontend-vue/public/i18n/de.json | 2 + src/frontend-vue/src/models/realmSettings.ts | 3 + .../src/views/admin/RealmSettingsView.vue | 5 + 25 files changed, 496 insertions(+), 14 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/Security/EmailFallbackSecondFactorTests.cs diff --git a/docs/admin/realm-settings.md b/docs/admin/realm-settings.md index a5d9d163..d3557d1b 100644 --- a/docs/admin/realm-settings.md +++ b/docs/admin/realm-settings.md @@ -186,10 +186,11 @@ The realm's sign-in policy (ADR 0025; concept: [Sign-in levels](../concepts/sign | **Administration minimum level** (`AdministrationMinimumLevel`) | The level a session needs for `/api/admin/*` (default `Multi`). Realm only; kept separate so end users of a code-only App are not pushed into 2FA by opening their profile. `Single` is meant for test and development realms; the UI warns about it | | **Setup grace days** (`SetupGraceDays`) | How long a user without a second factor may keep signing in at `Single` to something that requires `Multi`. `0` = set up immediately | | **First factors** | `Password`, `EmailCode`, `Passkey` — what the realm offers at all; an App can only offer a subset | -| **Second factors** | `Totp`, `EmailAfterPassword` | +| **Second factors** | `Totp`, `EmailAfterPassword`. With `EmailAfterPassword` on, the e-mail code also stands in for a user's own second factor that is gone (see [When the second factor is gone](../concepts/sign-in-levels#when-the-second-factor-is-gone)) | +| **E-mail fallback wait** (`EmailFallbackWaitHours`) | Hours after a password reset by e-mail during which the e-mail code does not stand in for a user's own second factor (default `24`, `0` = no wait, at most `720`). Realm only | | **User's own factor not offered** (`OwnFactorNotOffered`) | `Ignore` or `RequireViaBrowser` — what happens when a user switched on a second factor that the App does not offer | -New realms are seeded with: minimum `Single`, administration minimum `Multi`, grace 14 days, password on, e-mail code off, passkey on, TOTP on, e-mail after password on, `RequireViaBrowser`. A realm that never saved the section runs with the same values, except that the e-mail code is on when native grants are switched on for the realm or any App (see [Upgrading](../concepts/sign-in-levels#upgrading-from-0-14-and-earlier)). A policy that cannot be satisfied (`Multi` required, no way to reach it) is rejected on save. In a realm manifest the section lives under `Settings.SignIn`. +New realms are seeded with: minimum `Single`, administration minimum `Multi`, grace 14 days, password on, e-mail code off, passkey on, TOTP on, e-mail after password on, e-mail fallback wait 24 hours, `RequireViaBrowser`. A realm that never saved the section runs with the same values, except that the e-mail code is on when native grants are switched on for the realm or any App (see [Upgrading](../concepts/sign-in-levels#upgrading-from-0-14-and-earlier)). A policy that cannot be satisfied (`Multi` required, no way to reach it) is rejected on save. In a realm manifest the section lives under `Settings.SignIn`. ## Sessions diff --git a/docs/concepts/sign-in-levels.md b/docs/concepts/sign-in-levels.md index 58a44609..cab03916 100644 --- a/docs/concepts/sign-in-levels.md +++ b/docs/concepts/sign-in-levels.md @@ -115,6 +115,14 @@ need a recent proof (within **15 minutes**) of an account factor the user has. E "Recent" is read from the session: a sign-in a minute ago is the proof, and nothing is asked again. Adding the *first* account factor needs only the proof of what the user has; adding a further one needs the existing one. Every account change sends a notice to the account's e-mail address. +### When the second factor is gone + +A passkey lives on a device and an authenticator app on a phone; both can be lost. After a **password**, the login page therefore offers the e-mail code beside the user's own second factor, wherever the target offers the e-mail code after a password. The user does not have to switch it on; the address must be confirmed. The same proof — a session signed in with the password, the mailbox proven within the window — confirms an account change, so the user can remove the factor that is gone and set up a new one. + +It stands in for a factor the user has; it is no substitute for having one. A user without any second factor still gets the setup duty, and the fallback never raises the level a sign-in must reach. + +A password that was just reset by e-mail does not count: for the realm's **wait** (`EmailFallbackWaitHours`, default 24) the fallback is not offered, because the password and the code would both come from the same mailbox. With the fallback on, whoever holds the mailbox for longer than the wait can take over an account that has a password. A realm that does not accept that switches "E-mail code after a password" off; then a lost factor is reset by the operator with the [recovery CLI](/operate/recovery-cli). + **Account deletion is always reachable.** No minimum level and no setup grace ever block it; it needs the same proof as every other account change. Account-change endpoints answer `403 { "RequiresReauthentication": true, "Methods": [...] }` when the proof is missing or too old; the SPA asks for it and retries. @@ -179,6 +187,9 @@ Each scenario has a fixed number; the integration tests carry the same number. U | S12 | A session signed in with an e-mail code opens the admin console | The administration asks for the missing factor (step-up) or, without one, for its setup | | S13 | A session below the required level opens any page of Modgud's UI | The page loads and shows the step-up or the setup, never a raw error body | | S14 | An administrator signs in to Modgud itself with a password or an e-mail code | The second factor is asked before the sign-in completes; without one, the setup is offered (grace) or required. The same administrator signing in for App A follows App A's rules | +| S15 | An administrator's only second factor is a passkey on a device that is gone; they sign in with the password | The e-mail code is offered beside the passkey; with it the session is `multi`, and the same session removes the passkey | +| S16 | The same, but the password was reset by e-mail an hour ago and the realm's wait is 24 hours | Only the passkey is offered; no code is sent | +| S17 | A password-only session wants to remove a passkey that is an account factor | The page asks for the passkey or a fresh e-mail code | ### The account diff --git a/docs/decisions/0025-sign-in-assurance-is-required-by-the-target-app.md b/docs/decisions/0025-sign-in-assurance-is-required-by-the-target-app.md index f68141c4..b5cc3f36 100644 --- a/docs/decisions/0025-sign-in-assurance-is-required-by-the-target-app.md +++ b/docs/decisions/0025-sign-in-assurance-is-required-by-the-target-app.md @@ -307,3 +307,41 @@ With the realm at `single` and the administration at `multi`, an administrator w - An administrator who has lost their second factor can no longer sign in to Modgud at the portal's level to look around; the way back in is another administrator or the recovery CLI, as it already was for the administration itself. - An administrator without a second factor sees the setup offer at every sign-in to Modgud during the grace, not only after the first visit to the administration. - Scenario S14 is new; S43 now describes the session that comes from an App. + +## Amendment 3 — the e-mail code stands in for a second factor that is gone + +### Why + +An administrator enrolled a passkey as their only second factor and the device broke. The sign-in asked for that passkey and offered nothing else. The e-mail code could not be switched on, because switching it on is an account change and an account change needs the account factor — the same passkey. The only way back in was the recovery CLI, which a realm administrator cannot reach: it needs a shell on the server. Amendment 2 names "another administrator" as a way back in; the admin console has no such action. + +"Password + e-mail code" already is `multi` (§1). It was only offered to users who had switched it on. + +### Decision + +- **After a password, the e-mail code is offered beside the user's own second factor** — TOTP or a usable passkey — wherever the target offers the e-mail code after a password (`EmailAfterPassword`). The user does not have to switch it on. It needs a confirmed e-mail address. +- **It stands in; it does not replace the setup.** A user without any second factor still gets the setup duty (grace, then required). The fallback creates no demand of its own: it never raises the level a sign-in must reach. The user's own switch keeps its meaning (§7). +- **Still only after a password.** After an e-mail code or a magic link, a second e-mail code proves the same mailbox again. An account without a password has no fallback. +- **The same proof confirms an account change.** A session that was signed in with the password and proved the mailbox within the window (C) may change what protects the account — remove the passkey that is gone, set up an authenticator app. The login page offers the e-mail code in that step. +- **A password that came through the mailbox does not count.** For `EmailFallbackWaitHours` (realm setting, default 24, `0` = no wait) after a password reset by e-mail, the fallback is neither offered at sign-in nor accepted for an account change: the password and the code would be one proof. A password the user changed while signed in, or one an administrator set, starts no wait. +- **A realm switches the fallback off** by not offering the e-mail code after a password. Then the ways back in are the recovery CLI or an administrator action, as before. + +### What this changes about "what the mailbox alone can do" + +The table in C gains a time dimension for a user with an account factor and a password: + +| The user has… | With the mailbox alone, an attacker… | +|---|---| +| An account factor and a password, fallback on | resets the password and, once the wait is over, signs in with the e-mail code and can change the account. The reset ends every session, and the reset and every account change send a notice to the address | +| An account factor, fallback off or no password | as before: no account change | + +The wait does not stop an attacker who holds the mailbox for longer than the wait and whose reset goes unnoticed. It stops the reset-then-code sequence from being a single step, and it gives the owner the length of the wait to react to a reset they did not ask for. A realm that needs "the mailbox alone is never enough" switches the fallback off. + +This reverses an earlier hardening on purpose: the e-mail second factor was issued only to users who had switched it on, so that an account with TOTP could not be reduced to its mailbox. The switch still gates the code for a user without another factor; beside another factor, the wait takes its place. + +### Consequences + +- A user who lost their only second factor signs in with password and e-mail code, removes the lost factor and sets up a new one, without an administrator or the operator. +- Users with TOTP or a passkey see "Code via Email" as a further choice after their password where the target offers it. +- Not covered: an account without a password, and a realm that does not offer the e-mail code after a password. Recovery codes and a reset by another administrator in the admin console remain possible additions; neither is decided here. +- New realm setting `EmailFallbackWaitHours`; `UserSecurityData.PasswordResetByEmailAt` records the last reset by e-mail. +- Scenarios S15–S17 are new. diff --git a/docs/end-user/first-steps.md b/docs/end-user/first-steps.md index db397724..efd9f8ca 100644 --- a/docs/end-user/first-steps.md +++ b/docs/end-user/first-steps.md @@ -45,7 +45,7 @@ Profile → Account. Make sure your first name, last name, and email are correct - **Forgot password but have email**: use "Forgot password?" on the login page → a password-reset link arrives in your inbox → click it to set a new password directly. - **Forgot password, no email access**: contact your admin. They can send you a sign-in link. - **Lost your 2FA device, but still have another method enrolled** (e.g. a passkey): sign in with that method, then remove the lost device from Profile → Security and enrol a replacement. -- **Lost your only 2FA device**: contact your admin. They can reset 2FA for you — you'll re-enrol from scratch. +- **Lost your only 2FA device**: sign in with your password and choose **Code via Email** as the second step. Then remove the lost device from Profile → Security and enrol a replacement. If that choice is not offered (your organisation switched it off, you have no password, or you reset your password by e-mail in the last day), contact your admin. ## Tips diff --git a/docs/end-user/passkey.md b/docs/end-user/passkey.md index 1a36def5..3216187c 100644 --- a/docs/end-user/passkey.md +++ b/docs/end-user/passkey.md @@ -47,7 +47,7 @@ Deleting a passkey on Modgud's side **only removes the registration** — your d Two scenarios: - **You have other passkeys / 2FA methods**: sign in with one of those, delete the lost device's passkey from Modgud, enrol a fresh one. -- **You have nothing else**: contact your admin. They can reset 2FA for you; you'll re-enrol from a clean slate. +- **You have nothing else**: sign in with your password and choose **Code via Email** as the second step; then delete the lost device's passkey and enrol a fresh one. The code is not offered if your organisation switched it off, if you have no password, or for a day after you reset your password by e-mail — then contact your admin. ## Browser support diff --git a/docs/end-user/two-factor.md b/docs/end-user/two-factor.md index 89817118..a09b27e3 100644 --- a/docs/end-user/two-factor.md +++ b/docs/end-user/two-factor.md @@ -46,7 +46,7 @@ Some realms require 2FA. If you try to disable your only remaining method there, Two scenarios: - **You have another 2FA method enrolled** (e.g. a passkey) → sign in with that instead, then remove the lost authenticator from Profile → Security and enrol a replacement. -- **The authenticator was your only method** → contact your admin. They can `Reset 2FA completely` for you, opening a fresh grace period in which you can re-enrol. +- **The authenticator was your only method** → sign in with your password and choose **Code via Email** as the second step; then remove the lost authenticator from Profile → Security and enrol a replacement. The code is not offered if your organisation switched it off, if you have no password, or for a day after you reset your password by e-mail — then contact your admin. ## Multiple methods diff --git a/docs/operate/recovery-cli.md b/docs/operate/recovery-cli.md index 30c898df..60d76b60 100644 --- a/docs/operate/recovery-cli.md +++ b/docs/operate/recovery-cli.md @@ -295,8 +295,12 @@ to the console, not recorded. - **No admin can sign in** → `bootstrap-admin` (Direct mode) creates a fresh admin in one shot. -- **A user lost their 2FA device** → `reset-2fa ` then - `magic-link ` so they can log in and re-enrol. +- **A user lost their 2FA device** and cannot get back in themselves → + `reset-2fa ` then `magic-link ` so they can log in + and re-enrol. A user with a password usually does not need this: the + e-mail code stands in for the lost factor (see + [When the second factor is gone](../concepts/sign-in-levels#when-the-second-factor-is-gone)), + unless the realm switched that off or the account has no password. - **Production hostname doesn't route to a realm** → `realm-list` to confirm what's configured, then `realm-add-domain` to bind the new hostname, then `realm-set-primary-domain` to make it the realm's canonical primary. - **Marten projections out of sync after a schema change** → `rebuild-projections`. diff --git a/src/dotnet/Modgud.Api.Tests/Security/EmailFallbackSecondFactorTests.cs b/src/dotnet/Modgud.Api.Tests/Security/EmailFallbackSecondFactorTests.cs new file mode 100644 index 00000000..5a50e585 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Security/EmailFallbackSecondFactorTests.cs @@ -0,0 +1,220 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Marten; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.RealmSettings; +using Modgud.Authentication.Domain; +using Modgud.Authentication.RealmSettings; +using Modgud.Infrastructure.Email; +using Modgud.Infrastructure.Persistence.Tenancy; + +namespace Modgud.Api.Tests.Security; + +/// +/// ADR 0025, amendment 3 — a user whose own second factor is gone (a passkey on a broken +/// device) gets back in with the password and an e-mail code, and removes the dead factor +/// with the same proof. A password that was just reset through the mailbox does not count +/// for it until the realm's wait is over. +/// +[Collection(IntegrationTestCollection.Name)] +public partial class EmailFallbackSecondFactorTests : IntegrationTestBase +{ + public EmailFallbackSecondFactorTests(SharedPostgresFixture fixture) : base(fixture) { } + + private const string TestEmail = "test@test.com"; + + [Fact] + public async Task S15_an_administrator_whose_passkey_is_gone_signs_in_with_password_and_e_mail_code_and_removes_it() + { + var ct = TestContext.Current.CancellationToken; + await PatchSignInAsync(new UpdateSignInPolicyDto { AdministrationMinimumLevel = "Multi" }); + var passkeyId = await SeedPasskeyAsync(DefaultUser!.Id); + var browser = Factory.CreateDefaultClient(new CookieContainerHandler()); + + // The user never switched the e-mail code on; it is offered beside the passkey. + var login = await PasswordLoginAsync(browser, "tu", DefaultPassword, returnUrl: null); + var methods = await MfaMethodsAsync(login); + Assert.Contains("passkey", methods); + Assert.Contains("email", methods); + + await ConfirmWithEmailCodeAsync(browser, TestEmail); + + // Signed in at the administration's level … + Assert.Equal(HttpStatusCode.OK, (await browser.GetAsync("/api/admin/realm-settings", ct)).StatusCode); + // … and the same proof removes the factor that is gone. + var removed = await browser.DeleteAsync($"/api/account/passkey/{passkeyId}", ct); + Assert.True(removed.IsSuccessStatusCode, await removed.Content.ReadAsStringAsync(ct)); + Assert.Null(await Factory.GetDocumentAsync(passkeyId)); + } + + [Fact] + public async Task S17_a_password_session_confirms_an_account_change_with_a_fresh_e_mail_code() + { + var ct = TestContext.Current.CancellationToken; + await PatchSignInAsync(new UpdateSignInPolicyDto { AdministrationMinimumLevel = "Multi" }); + var passkeyId = await SeedPasskeyAsync(DefaultUser!.Id); + // Signed in for an App: a password-only session, the passkey never used. + var browser = Factory.CreateDefaultClient(new CookieContainerHandler()); + var login = await PasswordLoginAsync(browser, "tu", DefaultPassword, AppReturnUrl); + Assert.Contains("Login successful", await login.Content.ReadAsStringAsync(ct)); + + var refused = await browser.DeleteAsync($"/api/account/passkey/{passkeyId}", ct); + var refusedBody = await refused.Content.ReadFromJsonAsync(ct); + Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode); + Assert.True(refusedBody.GetProperty("RequiresReauthentication").GetBoolean()); + + var stepUp = await browser.PostAsJsonAsync("/api/account/step-up", new { Reauthenticate = true }, ct); + Assert.Contains("email", await MfaMethodsAsync(stepUp)); + await ConfirmWithEmailCodeAsync(browser, TestEmail); + + var removed = await browser.DeleteAsync($"/api/account/passkey/{passkeyId}", ct); + Assert.True(removed.IsSuccessStatusCode, await removed.Content.ReadAsStringAsync(ct)); + } + + [Fact] + public async Task S16_a_password_reset_by_e_mail_does_not_open_the_e_mail_fallback_until_the_wait_is_over() + { + var ct = TestContext.Current.CancellationToken; + await PatchSignInAsync(new UpdateSignInPolicyDto { AdministrationMinimumLevel = "Multi", EmailFallbackWaitHours = 24 }); + var admin = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Reset", lastname: "Admin", acronym: "RA", email: "reset-admin@test.com", + password: DefaultPassword, isRealmAdmin: true); + await SeedPasskeyAsync(admin.Id); + + // Whoever holds the mailbox sets a new password … + const string newPassword = "NewPass4567!"; + string token; + using (var scope = Factory.Services.CreateScope()) + { + var users = scope.ServiceProvider.GetRequiredService>(); + token = await users.GeneratePasswordResetTokenAsync((await users.FindByIdAsync(admin.Id.ToString()))!); + } + var reset = await Factory.CreateClient().PostAsJsonAsync("/api/account/reset-password", + new { UserId = admin.Id.ToString(), Token = token, NewPassword = newPassword }, ct); + Assert.True(reset.IsSuccessStatusCode, await reset.Content.ReadAsStringAsync(ct)); + // The owner is told, so a reset they did not ask for does not go unnoticed during the wait. + Assert.NotNull(Factory.Services.GetRequiredService().GetLastEmailTo("reset-admin@test.com")); + + // … and is still asked for the passkey alone: password and code would be one proof. + var browser = Factory.CreateDefaultClient(new CookieContainerHandler()); + var login = await PasswordLoginAsync(browser, "ra", newPassword, returnUrl: null); + Assert.Equal(["passkey"], await MfaMethodsAsync(login)); + var request = await browser.PostAsync("/api/account/email-otp/login/request", null, ct); + Assert.Equal(HttpStatusCode.BadRequest, request.StatusCode); + // A code from the e-mail-code sign-in — the same mailbox — is no way around the wait. + var foreignCode = await IssueSignInCodeAsync(admin.Id, "reset-admin@test.com"); + var smuggled = await browser.PostAsJsonAsync("/api/account/email-otp/login", new { Code = foreignCode }, ct); + Assert.Equal(HttpStatusCode.Unauthorized, smuggled.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await browser.GetAsync("/api/account/me", ct)).StatusCode); + + // A realm without a wait offers it at once. + await PatchSignInAsync(new UpdateSignInPolicyDto { EmailFallbackWaitHours = 0 }); + var later = await PasswordLoginAsync(Factory.CreateDefaultClient(new CookieContainerHandler()), "ra", newPassword, returnUrl: null); + Assert.Contains("email", await MfaMethodsAsync(later)); + } + + [Fact] + public async Task A_realm_that_does_not_offer_the_e_mail_code_after_a_password_has_no_e_mail_fallback() + { + await PatchSignInAsync(new UpdateSignInPolicyDto { AdministrationMinimumLevel = "Multi", EmailAfterPassword = false }); + await SeedPasskeyAsync(DefaultUser!.Id); + var browser = Factory.CreateDefaultClient(new CookieContainerHandler()); + + var login = await PasswordLoginAsync(browser, "tu", DefaultPassword, returnUrl: null); + + Assert.Equal(["passkey"], await MfaMethodsAsync(login)); + var request = await browser.PostAsync("/api/account/email-otp/login/request", null, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, request.StatusCode); + } + + // ── Helpers ── + + /// A pending authorization as the login page's return URL: the sign-in is for + /// a client, not for Modgud's own UI. No App is bound to it, so the realm's policy applies. + private const string AppReturnUrl = "/connect/authorize?client_id=some-app&response_type=code"; + + private static Task PasswordLoginAsync(HttpClient browser, string userName, string password, string? returnUrl) => + browser.PostAsJsonAsync("/api/account/login", + new { UserName = userName, Password = password, ReturnUrl = returnUrl }, TestContext.Current.CancellationToken); + + private static async Task> MfaMethodsAsync(HttpResponseMessage response) + { + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.True(response.StatusCode == HttpStatusCode.OK, body.ToString()); + Assert.True(body.TryGetProperty("RequiresMfa", out var requiresMfa) && requiresMfa.GetBoolean(), body.ToString()); + return body.GetProperty("MfaMethods").EnumerateArray().Select(m => m.GetString()).ToList(); + } + + /// The second step of the browser: ask for the code, read it from the mail, send it. + private async Task ConfirmWithEmailCodeAsync(HttpClient browser, string address) + { + var ct = TestContext.Current.CancellationToken; + var mail = Factory.Services.GetRequiredService(); + mail.Clear(); + + var request = await browser.PostAsync("/api/account/email-otp/login/request", null, ct); + Assert.True(request.IsSuccessStatusCode, await request.Content.ReadAsStringAsync(ct)); + var sent = mail.GetLastEmailTo(address); + Assert.NotNull(sent); + var code = OtpCodeRegex().Match(sent!.HtmlBody).Groups[1].Value; + + var confirm = await browser.PostAsJsonAsync("/api/account/email-otp/login", new { Code = code }, ct); + Assert.True(confirm.IsSuccessStatusCode, await confirm.Content.ReadAsStringAsync(ct)); + } + + /// A code as the e-mail-code sign-in issues it (the first factor's path). + private async Task IssueSignInCodeAsync(Guid userId, string address) + { + var mail = Factory.Services.GetRequiredService(); + mail.Clear(); + using var scope = Factory.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService().HttpContext = + new Microsoft.AspNetCore.Http.DefaultHttpContext + { + Items = { [TenantConstants.HttpContextTenantIdKey] = TenantConstants.SystemTenantId }, + }; + var otp = scope.ServiceProvider.GetRequiredService(); + var issued = await otp.RequestNativeOtpAsync(userId, TestContext.Current.CancellationToken); + Assert.False(issued.IsError, "RequestNativeOtpAsync failed to issue a challenge"); + return OtpCodeRegex().Match(mail.GetLastEmailTo(address)!.HtmlBody).Groups[1].Value; + } + + private async Task PatchSignInAsync(UpdateSignInPolicyDto signIn) + { + using var scope = Factory.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService().HttpContext = + new Microsoft.AspNetCore.Http.DefaultHttpContext + { + Items = { [TenantConstants.HttpContextTenantIdKey] = TenantConstants.SystemTenantId }, + }; + var settings = scope.ServiceProvider.GetRequiredService(); + var result = await settings.PatchAsync(new UpdateRealmSettingsDto { SignIn = signIn }, TestContext.Current.CancellationToken); + Assert.False(result.IsError, string.Join(", ", result.Errors.Select(e => e.Description))); + } + + /// A passkey for the realm's own domain: usable on the login page, and an + /// account factor on the profile. + private async Task SeedPasskeyAsync(Guid userId) + { + var id = Guid.NewGuid(); + await using var session = GetTenantedDocumentSession(); + session.Store(new StoredPasskeyCredential + { + Id = id, + UserId = userId, + CredentialId = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32), + PublicKey = System.Security.Cryptography.RandomNumberGenerator.GetBytes(64), + UserHandle = userId.ToByteArray(), + DisplayName = "Passkey", + RpId = null, + }); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + return id; + } + + [System.Text.RegularExpressions.GeneratedRegex(@"\b(\d{6})\b")] + private static partial System.Text.RegularExpressions.Regex OtpCodeRegex(); +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs index cd43efe4..f95bc4cc 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs @@ -524,6 +524,7 @@ private static async Task ExportInboxSettingsAsync(I Passkey = s.SignIn.Passkey, Totp = s.SignIn.Totp, EmailAfterPassword = s.SignIn.EmailAfterPassword, + EmailFallbackWaitHours = s.SignIn.EmailFallbackWaitHours, OwnFactorNotOffered = s.SignIn.OwnFactorNotOffered, }, BrowserSessions = new UpdateBrowserSessionPolicyDto diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/SignInPolicyDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/SignInPolicyDtos.cs index 118ce7f1..b672fc50 100644 --- a/src/dotnet/Modgud.Application/DTOs/RealmSettings/SignInPolicyDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/SignInPolicyDtos.cs @@ -17,6 +17,7 @@ public record SignInPolicyDto public bool Passkey { get; init; } = true; public bool Totp { get; init; } = true; public bool EmailAfterPassword { get; init; } = true; + public int EmailFallbackWaitHours { get; init; } = 24; public string OwnFactorNotOffered { get; init; } = "RequireViaBrowser"; } @@ -34,5 +35,6 @@ public record UpdateSignInPolicyDto public bool? Passkey { get; init; } public bool? Totp { get; init; } public bool? EmailAfterPassword { get; init; } + public int? EmailFallbackWaitHours { get; init; } public string? OwnFactorNotOffered { get; init; } } diff --git a/src/dotnet/Modgud.Authentication/Api/Account/AccountChangeGuard.cs b/src/dotnet/Modgud.Authentication/Api/Account/AccountChangeGuard.cs index 11024818..51c00e95 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/AccountChangeGuard.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/AccountChangeGuard.cs @@ -46,6 +46,7 @@ public interface IAccountChangeGuard public sealed class AccountChangeGuard( IDocumentSession session, RpIdResolver rpIdResolver, + ISignInRequirementService signInRequirements, IEmailService email, IEmailBrandingResolver branding, ILogger logger) : IAccountChangeGuard @@ -69,6 +70,14 @@ public async Task CheckAsync(HttpContext http, ApplicationUs if (accountFactors.Count > 0) { var allowed = proven.Any(f => AccountFactorProofs.Contains(f.Key) && f.Value >= since); + // Amendment 3 — a session signed in with the password may confirm the change + // with a fresh e-mail code instead: the way to remove a factor that is gone. + if (proven.ContainsKey(SignInMethods.Password) + && await signInRequirements.EmailFallbackAvailableAsync(user, ct)) + { + allowed = allowed || (proven.TryGetValue(SignInMethods.Email, out var mailboxAt) && mailboxAt >= since); + accountFactors.Add(SignInMethods.Email); + } return new AccountChangeCheck(allowed, HasAccountFactor: true, accountFactors); } diff --git a/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs index 1ea232e7..d51b90b8 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs @@ -143,15 +143,25 @@ await staffingRevoker.EndAllForUserAsync( // POST /api/account/email-otp/login/request — Send OTP code via email group.MapPost("login/request", async ( + HttpContext context, SignInManager signInManager, IEmailOtpService emailOtpService, + Modgud.Authentication.SignIn.ISignInRequirementService signInRequirements, CancellationToken ct) => { var user = await signInManager.GetTwoFactorAuthenticationUserAsync(); if (user is null) return Results.Json(new { Message = "Invalid credentials" }, statusCode: 401); - var result = await emailOtpService.RequestOtpAsync(user.Id, ct); + // The user's own e-mail second factor, or — after a password — the code that + // stands in for a second factor they can no longer use (ADR 0025, amendment 3). + var partial = await Modgud.Authentication.SignIn.SignInAssurance.ReadPartialAsync(context); + var asFallback = !user.EmailOtpEnabled + && partial?.Factors.ContainsKey(Modgud.Authentication.SignIn.SignInMethods.Password) == true + && await signInRequirements.EmailFallbackAvailableAsync(user, ct); + var result = asFallback + ? await emailOtpService.RequestFallbackOtpAsync(user.Id, ct) + : await emailOtpService.RequestOtpAsync(user.Id, ct); if (result.IsError) { var error = result.FirstError; @@ -172,6 +182,7 @@ await staffingRevoker.EndAllForUserAsync( HttpContext context, SignInManager signInManager, IEmailOtpService emailOtpService, + Modgud.Authentication.SignIn.ISignInRequirementService signInRequirements, CancellationToken ct) => { // Empty body / missing field — reject at the boundary instead of letting the service NRE. @@ -199,9 +210,21 @@ await staffingRevoker.EndAllForUserAsync( } // ADR 0025 — an e-mail code is a second factor only after a password. After an - // e-mail-code or magic-link sign-in it would prove the same mailbox twice. + // e-mail-code or magic-link sign-in alone it would prove the same mailbox twice. + // A session that carries a password may prove its mailbox again: that is how an + // account change is confirmed with the e-mail fallback (amendment 3). var partial = await Modgud.Authentication.SignIn.SignInAssurance.ReadPartialAsync(context); - if (partial is null || partial.Value.Factors.ContainsKey(Modgud.Authentication.SignIn.SignInMethods.Email)) + if (partial is null || !partial.Value.Factors.ContainsKey(Modgud.Authentication.SignIn.SignInMethods.Password)) + { + ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.EmailOtp, ModgudMeters.LoginOutcome.Failure); + return Results.Json(new { Message = "Invalid credentials" }, statusCode: 401); + } + + // The code counts as this user's second factor only where one may be issued for + // it: their own e-mail second factor, or the fallback. A code issued on another + // path (the e-mail-code sign-in shares the challenge) must not get around that, + // or around the wait after a password reset by e-mail. + if (!user.EmailOtpEnabled && !await signInRequirements.EmailFallbackAvailableAsync(user, ct)) { ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.EmailOtp, ModgudMeters.LoginOutcome.Failure); return Results.Json(new { Message = "Invalid credentials" }, statusCode: 401); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/PasswordResetEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/PasswordResetEndpoints.cs index 4ac96adf..568534c7 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/PasswordResetEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/PasswordResetEndpoints.cs @@ -1,5 +1,7 @@ using System.Text; using System.Web; +using Marten; +using Marten.Patching; using Microsoft.AspNetCore.Identity; using Modgud.Authentication; using Modgud.Authentication.Domain; @@ -98,7 +100,9 @@ await emailBranding.ApplyAsync(new Dictionary UserManager userManager, Modgud.Authentication.Sessions.IUserAccessRevoker accessRevoker, Modgud.Infrastructure.PositionTerminals.IStaffingRevoker staffingRevoker, - Modgud.Authentication.SignIn.ISignInRequirementService signInRequirements) => + Modgud.Authentication.SignIn.ISignInRequirementService signInRequirements, + IAccountChangeGuard accountChanges, + IDocumentSession session) => { if (!await signInRequirements.PortalAllowsPasswordAsync()) return Results.Json(new { Message = "Password operations are disabled" }, statusCode: 403); @@ -124,6 +128,13 @@ await emailBranding.ApplyAsync(new Dictionary return Results.Json(new { Message = string.Join(" ", errors) }, statusCode: 400); } + // ADR 0025, amendment 3 — this password came through the mailbox. Until the + // realm's wait is over, an e-mail code does not stand in for the user's own + // second factor: password and code would be the same proof. An atomic patch, + // like the other fields of this document that Identity does not own. + session.Patch(user.Id).Set(x => x.PasswordResetByEmailAt, DateTimeOffset.UtcNow); + await session.SaveChangesAsync(); + // Audit remediation #3: the anonymous ATO-recovery path. ResetPasswordAsync // rotates the Identity stamp (cuts cookies + refresh-token continuation) but // touches no OAuth grants and no device-session rows — leaving an attacker's @@ -134,6 +145,9 @@ await staffingRevoker.EndAllForUserAsync( user.Id, Modgud.Domain.PositionTerminals.StaffingSessionEndReason.ActivationCredentialInvalidated); + // The owner hears of a reset they did not ask for while the wait above still runs. + await accountChanges.NotifyAsync(user, AccountChange.PasswordChanged); + return Results.Ok(new { Message = "Passwort wurde erfolgreich zurückgesetzt." }); }) .WithName("Account_ResetPassword"); diff --git a/src/dotnet/Modgud.Authentication/Domain/UserSecurityData.cs b/src/dotnet/Modgud.Authentication/Domain/UserSecurityData.cs index 2ef1ca52..039fe0f8 100644 --- a/src/dotnet/Modgud.Authentication/Domain/UserSecurityData.cs +++ b/src/dotnet/Modgud.Authentication/Domain/UserSecurityData.cs @@ -37,6 +37,11 @@ public class UserSecurityData // is expected to audit this flag; we log a warning on every exempt request. public bool TwoFactorExempt { get; set; } + // When the password was last set through the reset link sent to the mailbox. For the + // realm's EmailFallbackWaitHours after it, the e-mail code does not stand in for the + // user's own second factor: both proofs would come from the same mailbox. + public DateTimeOffset? PasswordResetByEmailAt { get; set; } + public static UserSecurityData Create(Guid userId, string? passwordHash = null) { return new UserSecurityData diff --git a/src/dotnet/Modgud.Authentication/Identity/EmailOtpService.cs b/src/dotnet/Modgud.Authentication/Identity/EmailOtpService.cs index 13d2510f..3e3e2842 100644 --- a/src/dotnet/Modgud.Authentication/Identity/EmailOtpService.cs +++ b/src/dotnet/Modgud.Authentication/Identity/EmailOtpService.cs @@ -32,6 +32,22 @@ public async Task> RequestOtpAsync(Guid userId, CancellationToken return await IssueChallengeAsync(user, ct); } + // ADR 0025, amendment 3 — the code that stands in for the user's own second factor. + // The opt-in gate above does not apply: the caller has checked that the fallback is + // available for this user. A confirmed, active mailbox is still required. + public async Task> RequestFallbackOtpAsync(Guid userId, CancellationToken ct) + { + var user = await session.LoadAsync(userId, ct); + if (user is null) + return Error.NotFound("EmailOtp.UserNotFound", "User not found."); + if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed) + return Error.Forbidden("EmailOtp.EmailNotConfirmed", "A confirmed email address is required."); + if (!user.IsActive || user.IsDeleted) + return Error.Forbidden("EmailOtp.AccountInactive", "The account cannot sign in."); + + return await IssueChallengeAsync(user, ct); + } + // ADR-0010 — native passwordless login. email-OTP acts here as a PRIMARY // factor, so (unlike the 2FA RequestOtpAsync) it does NOT require the user's // EmailOtpEnabled opt-in. It DOES require a confirmed, active mailbox: diff --git a/src/dotnet/Modgud.Authentication/Identity/IEmailOtpService.cs b/src/dotnet/Modgud.Authentication/Identity/IEmailOtpService.cs index 102e4318..ba21a3f5 100644 --- a/src/dotnet/Modgud.Authentication/Identity/IEmailOtpService.cs +++ b/src/dotnet/Modgud.Authentication/Identity/IEmailOtpService.cs @@ -8,6 +8,13 @@ public interface IEmailOtpService /// Gated on the user's EmailOtpEnabled opt-in. Task> RequestOtpAsync(Guid userId, CancellationToken ct); + /// ADR 0025, amendment 3 — issues an email-OTP code that stands in for the + /// user's own second factor after a password. Not gated on the EmailOtpEnabled + /// opt-in; the caller decides whether the fallback is available + /// (ISignInRequirementService.EmailFallbackAvailableAsync). Requires a confirmed, + /// active mailbox. + Task> RequestFallbackOtpAsync(Guid userId, CancellationToken ct); + /// ADR-0010 — issues an email-OTP code as a PRIMARY passwordless /// factor for native (cookieless) login. Unlike /// this does NOT require EmailOtpEnabled, but it DOES require a diff --git a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs index 76f80883..77d5e0a2 100644 --- a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs @@ -536,6 +536,7 @@ private async Task> ApplySignInPatchAsync( var ownFactor = SignInPolicyRules.ParseOwnFactor(patch.OwnFactorNotOffered); if (ownFactor.IsError) return ownFactor.FirstError; if (SignInPolicyRules.ValidateGraceDays(patch.SetupGraceDays) is { } graceError) return graceError; + if (SignInPolicyRules.ValidateEmailFallbackWaitHours(patch.EmailFallbackWaitHours) is { } waitError) return waitError; var merged = s with { @@ -547,6 +548,7 @@ private async Task> ApplySignInPatchAsync( Passkey = patch.Passkey ?? s.Passkey, Totp = patch.Totp ?? s.Totp, EmailAfterPassword = patch.EmailAfterPassword ?? s.EmailAfterPassword, + EmailFallbackWaitHours = patch.EmailFallbackWaitHours ?? s.EmailFallbackWaitHours, OwnFactorNotOffered = ownFactor.Value ?? s.OwnFactorNotOffered, }; @@ -566,6 +568,7 @@ private async Task> ApplySignInPatchAsync( Passkey = s.Passkey, Totp = s.Totp, EmailAfterPassword = s.EmailAfterPassword, + EmailFallbackWaitHours = s.EmailFallbackWaitHours, OwnFactorNotOffered = s.OwnFactorNotOffered.ToString(), }; diff --git a/src/dotnet/Modgud.Authentication/RealmSettings/SignInPolicyRules.cs b/src/dotnet/Modgud.Authentication/RealmSettings/SignInPolicyRules.cs index d8fcaec1..56cecf32 100644 --- a/src/dotnet/Modgud.Authentication/RealmSettings/SignInPolicyRules.cs +++ b/src/dotnet/Modgud.Authentication/RealmSettings/SignInPolicyRules.cs @@ -38,6 +38,11 @@ days is < 0 or > 365 ? Error.Validation("SignIn.InvalidSetupGraceDays", "SetupGraceDays must be between 0 and 365.") : null; + public static Error? ValidateEmailFallbackWaitHours(int? hours) => + hours is < 0 or > 720 + ? Error.Validation("SignIn.InvalidEmailFallbackWaitHours", "EmailFallbackWaitHours must be between 0 and 720.") + : null; + /// Null/blank passes (no RP ID). Returns the trimmed value via . public static Error? ValidatePasskeyRpId(string? raw, out string? normalized) { diff --git a/src/dotnet/Modgud.Authentication/SignIn/SignInRequirementService.cs b/src/dotnet/Modgud.Authentication/SignIn/SignInRequirementService.cs index 54d8dc1a..9c687a28 100644 --- a/src/dotnet/Modgud.Authentication/SignIn/SignInRequirementService.cs +++ b/src/dotnet/Modgud.Authentication/SignIn/SignInRequirementService.cs @@ -100,6 +100,13 @@ Task ResolveTargetAsync( /// for password reset and change. Task PortalAllowsPasswordAsync(CancellationToken ct = default); + /// Whether the e-mail code after a password may stand in for the user's own + /// second factor (ADR 0025, amendment 3) — the way back in for a user whose passkey or + /// authenticator app is gone. It needs a password, a confirmed address, a second factor + /// to stand in for, the realm offering the e-mail code after a password, and a password + /// that was not just reset through the same mailbox. + Task EmailFallbackAvailableAsync(ApplicationUser user, CancellationToken ct = default); + /// Evaluate for against /// . With a user who owes a /// second factor and has none gets their grace started (persisted). @@ -271,9 +278,31 @@ internal static SignInPolicy Strictest(SignInPolicy a, SignInPolicy b) => a with Passkey = a.Passkey && b.Passkey, Totp = a.Totp && b.Totp, EmailAfterPassword = a.EmailAfterPassword && b.EmailAfterPassword, + EmailFallbackWaitHours = Math.Max(a.EmailFallbackWaitHours, b.EmailFallbackWaitHours), OwnFactorNotOffered = (OwnFactorNotOffered)Math.Max((int)a.OwnFactorNotOffered, (int)b.OwnFactorNotOffered), }; + public async Task EmailFallbackAvailableAsync(ApplicationUser user, CancellationToken ct = default) + { + var realm = (await settingsResolver.ResolveAsync(null, ct)).SignIn ?? SignInPolicy.Defaults; + if (!realm.EmailAfterPassword) return false; + var hasOwnFactor = user.TwoFactorEnabled + || await session.Query().AnyAsync(c => c.UserId == user.Id, ct); + var security = await session.LoadAsync(user.Id, ct); + return EmailFallbackAllowed(user, security, hasOwnFactor, realm.EmailFallbackWaitHours, DateTimeOffset.UtcNow); + } + + /// The user-side conditions of the e-mail fallback; whether the target offers + /// the e-mail code after a password is the policy's part. + internal static bool EmailFallbackAllowed( + ApplicationUser user, UserSecurityData? security, bool hasOwnFactor, int waitHours, DateTimeOffset now) => + hasOwnFactor + && user.EmailConfirmed + && !string.IsNullOrEmpty(user.Email) + // The hash lives in the security document; the user carries it only when the store loaded it. + && !string.IsNullOrEmpty(user.PasswordHash ?? security?.PasswordHash) + && !(security?.PasswordResetByEmailAt is { } resetAt && resetAt.AddHours(Math.Max(0, waitHours)) > now); + public Task EvaluateAsync( ApplicationUser user, SignInTarget target, @@ -320,6 +349,9 @@ private async Task EvaluateAsync( HasEmailSecondFactor: user.EmailOtpEnabled && !string.IsNullOrEmpty(user.Email), HasUsablePasskey: hasUsablePasskey, HasAnySecondFactor: hasAnySecondFactor, + EmailFallback: EmailFallbackAllowed( + user, security, user.TwoFactorEnabled || passkeyRpIds.Count > 0, + target.Policy.EmailFallbackWaitHours, DateTimeOffset.UtcNow), Exempt: security?.TwoFactorExempt == true, SetupDueAt: security?.SecureSetupDueAt is { } due ? new DateTimeOffset(DateTime.SpecifyKind(due, DateTimeKind.Utc)) : null); @@ -351,6 +383,7 @@ internal sealed record SignInFacts( bool HasEmailSecondFactor, bool HasUsablePasskey, bool HasAnySecondFactor, + bool EmailFallback, bool Exempt, DateTimeOffset? SetupDueAt); @@ -370,6 +403,10 @@ internal static SignInDecision Evaluate( var webTotp = user.HasTotp && !proven.Contains(SignInMethods.Totp) && (policy.Totp || viaBrowser); var webEmail = user.HasEmailSecondFactor && passwordOnly && (policy.EmailAfterPassword || viaBrowser); var webPasskey = user.HasUsablePasskey && policy.Passkey && !proven.Contains(SignInMethods.Passkey); + // Amendment 3 — the e-mail code beside the user's own second factor, for when that + // factor is gone. Offered where the App offers the e-mail code after a password; it + // is not the user's own demand, so it never raises the level. + var fallbackEmail = user.EmailFallback && passwordOnly && policy.EmailAfterPassword && (webTotp || webPasskey); // The user's own second factor: a demand wherever it can be honoured. var ownDemand = (user.HasTotp && (policy.Totp || viaBrowser)) @@ -384,7 +421,7 @@ internal static SignInDecision Evaluate( if (surface == SignInSurface.Web) { if (webTotp) usable.Add(SignInMethods.Totp); - if (webEmail) usable.Add(SignInMethods.Email); + if (webEmail || fallbackEmail) usable.Add(SignInMethods.Email); if (webPasskey) usable.Add(SignInMethods.Passkey); } else @@ -392,6 +429,7 @@ internal static SignInDecision Evaluate( // Natively the App can only ask for what it implements; the rest needs the browser. if (webTotp) (policy.Totp ? usable : browserOnly).Add(SignInMethods.Totp); if (webEmail) (policy.EmailAfterPassword ? usable : browserOnly).Add(SignInMethods.Email); + // The e-mail fallback is a browser matter: no native grant starts with a password. // A passkey bound to the App's RP ID is used through the native passkey grant. if (webPasskey) usable.Add(SignInMethods.Passkey); } diff --git a/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs b/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs index dc4a6bfe..985cef4c 100644 --- a/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs +++ b/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs @@ -194,6 +194,7 @@ public static SignInPolicy ApplySignInOverrides( Passkey = floor.Passkey && (app?.Passkey ?? b.Passkey), Totp = floor.Totp && (app?.Totp ?? b.Totp), EmailAfterPassword = floor.EmailAfterPassword && (app?.EmailAfterPassword ?? b.EmailAfterPassword), + EmailFallbackWaitHours = floor.EmailFallbackWaitHours, OwnFactorNotOffered = app?.OwnFactorNotOffered ?? b.OwnFactorNotOffered, }; } diff --git a/src/dotnet/Modgud.Domain/Realms/SignInPolicy.cs b/src/dotnet/Modgud.Domain/Realms/SignInPolicy.cs index 1fd05df8..92429cc4 100644 --- a/src/dotnet/Modgud.Domain/Realms/SignInPolicy.cs +++ b/src/dotnet/Modgud.Domain/Realms/SignInPolicy.cs @@ -71,6 +71,12 @@ public record SignInPolicy /// magic-link sign-in: it would prove the same mailbox twice. public bool EmailAfterPassword { get; init; } = true; + /// Realm-level only: hours after a password reset by e-mail during which the + /// e-mail code does not stand in for a user's own second factor. A password that just + /// came through the mailbox and a code sent to the same mailbox are one proof, not two. + /// 0 = no wait. App overrides do not touch it. + public int EmailFallbackWaitHours { get; init; } = 24; + /// What happens when a user has their own second factor that this App /// does not offer. public OwnFactorNotOffered OwnFactorNotOffered { get; init; } = OwnFactorNotOffered.RequireViaBrowser; diff --git a/src/dotnet/Modgud.Tests.Unit/Authentication/SignIn/SignInRequirementEvaluationTests.cs b/src/dotnet/Modgud.Tests.Unit/Authentication/SignIn/SignInRequirementEvaluationTests.cs index 8d0e855c..ff76f804 100644 --- a/src/dotnet/Modgud.Tests.Unit/Authentication/SignIn/SignInRequirementEvaluationTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Authentication/SignIn/SignInRequirementEvaluationTests.cs @@ -16,8 +16,9 @@ public class SignInRequirementEvaluationTests private static Facts User( bool totp = false, bool emailSecondFactor = false, bool usablePasskey = false, - bool anySecondFactor = false, bool exempt = false, DateTimeOffset? dueAt = null) => - new(totp, emailSecondFactor, usablePasskey, anySecondFactor || totp || emailSecondFactor || usablePasskey, exempt, dueAt); + bool anySecondFactor = false, bool exempt = false, DateTimeOffset? dueAt = null, bool emailFallback = false) => + new(totp, emailSecondFactor, usablePasskey, anySecondFactor || totp || emailSecondFactor || usablePasskey, + emailFallback, exempt, dueAt); private static IReadOnlySet Proven(params string[] methods) => methods.ToHashSet(StringComparer.Ordinal); @@ -241,4 +242,71 @@ public void An_app_only_raises_the_realm_floor() Assert.False(effective.Password); Assert.Equal(["MinimumLevel", "EmailCode"], EffectiveSettings.BelowFloor(floor, below)); } + + // ── Amendment 3 — the e-mail code stands in for a second factor that is gone ── + + private static readonly SignInPolicy MultiApp = SignInPolicy.Defaults with { MinimumLevel = SignInLevel.Multi }; + + [Fact] + public void After_a_password_the_e_mail_code_is_offered_beside_the_users_own_second_factor() + { + var withPasskey = Evaluate(MultiApp, User(usablePasskey: true, emailFallback: true), Proven(SignInMethods.Password)); + var withTotp = Evaluate(MultiApp, User(totp: true, emailFallback: true), Proven(SignInMethods.Password)); + + Assert.Equal([SignInMethods.Email, SignInMethods.Passkey], withPasskey.SecondFactors); + Assert.Equal([SignInMethods.Totp, SignInMethods.Email], withTotp.SecondFactors); + } + + [Fact] + public void The_e_mail_fallback_is_not_offered_after_an_e_mail_code_or_where_the_app_does_not_offer_it() + { + var user = User(usablePasskey: true, emailFallback: true); + + var afterCode = Evaluate(MultiApp with { EmailCode = true }, user, Proven(SignInMethods.Email)); + var notOffered = Evaluate(MultiApp with { EmailAfterPassword = false }, user, Proven(SignInMethods.Password)); + + Assert.Equal([SignInMethods.Passkey], afterCode.SecondFactors); + Assert.Equal([SignInMethods.Passkey], notOffered.SecondFactors); + } + + [Fact] + public void The_e_mail_fallback_replaces_no_setup_and_raises_no_level() + { + // Nothing to stand in for: the setup duty stays what it was. + var nothingToReplace = Evaluate(MultiApp, User(emailFallback: true), Proven(SignInMethods.Password)); + // A single-level App asks for nothing because the fallback exists. + var singleApp = Evaluate(SignInPolicy.Defaults, User(usablePasskey: true, emailFallback: true), Proven(SignInMethods.Password)); + + Assert.Equal(SignInOutcome.Satisfied, nothingToReplace.Outcome); + Assert.True(nothingToReplace.SetupPending); + Assert.Equal(SignInOutcome.Satisfied, singleApp.Outcome); + Assert.False(singleApp.SetupPending); + } + + [Theory] + [InlineData(1, 24, false)] // the password just came through the mailbox + [InlineData(25, 24, true)] // the wait is over + [InlineData(1, 0, true)] // the realm set no wait + public void A_password_reset_by_e_mail_holds_the_fallback_back_for_the_realms_wait(int hoursAgo, int waitHours, bool expected) + { + var user = new Modgud.Authentication.Domain.ApplicationUser + { + Email = "a@example.com", EmailConfirmed = true, PasswordHash = "hash", + }; + var security = new Modgud.Authentication.Domain.UserSecurityData { PasswordResetByEmailAt = Now.AddHours(-hoursAgo) }; + + Assert.Equal(expected, SignInRequirementService.EmailFallbackAllowed(user, security, hasOwnFactor: true, waitHours, Now)); + } + + [Fact] + public void The_e_mail_fallback_needs_a_password_a_confirmed_address_and_a_factor_to_stand_in_for() + { + Modgud.Authentication.Domain.ApplicationUser Account(bool confirmed = true, string? password = "hash") => + new() { Email = "a@example.com", EmailConfirmed = confirmed, PasswordHash = password }; + + Assert.True(SignInRequirementService.EmailFallbackAllowed(Account(), null, hasOwnFactor: true, 24, Now)); + Assert.False(SignInRequirementService.EmailFallbackAllowed(Account(confirmed: false), null, hasOwnFactor: true, 24, Now)); + Assert.False(SignInRequirementService.EmailFallbackAllowed(Account(password: null), null, hasOwnFactor: true, 24, Now)); + Assert.False(SignInRequirementService.EmailFallbackAllowed(Account(), null, hasOwnFactor: false, 24, Now)); + } } diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index d1914691..e1814217 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -2446,6 +2446,8 @@ "administrationSingleWarning": "Wer diesen Realm verwaltet, braucht dann keinen zweiten Faktor. Nur für Test- und Entwicklungs-Realms gedacht.", "setupGraceDays": "Einrichtungsfrist (Tage, 0–365)", "setupGraceDaysHint": "Wie lange sich ein Nutzer ohne zweiten Faktor weiter bei einer App anmelden darf, die mehrere Faktoren verlangt. 0 = sofort einrichten.", + "emailFallbackWaitHours": "E-Mail-Code als Ersatz: Wartezeit nach Passwort-Zurücksetzen (Stunden, 0–720)", + "emailFallbackWaitHoursHint": "Nach dem Passwort kann der E-Mail-Code einen zweiten Faktor ersetzen, den der Nutzer nicht mehr verwenden kann (verlorener Passkey oder Authenticator-App). Ein Passwort, das gerade per E-Mail zurückgesetzt wurde, zählt dafür erst nach der Wartezeit. 0 = keine Wartezeit. „E-Mail-Code nach dem Passwort“ ausschalten, um keinen Ersatz anzubieten.", "ownFactorNotOffered": "Eigener zweiter Faktor des Nutzers, von der App nicht angeboten", "ownFactorNotOfferedHint": "Was passiert, wenn ein Nutzer einen zweiten Faktor (z. B. eine Authenticator-App) eingeschaltet hat, den eine App nicht anbietet.", "ownFactorIgnoreNotice": "Nutzer, die einen eigenen zweiten Faktor eingerichtet haben (z. B. eine Authenticator-App), werden bei der Anmeldung an dieser App nicht danach gefragt, solange die App ihn nicht anbietet.", diff --git a/src/frontend-vue/src/models/realmSettings.ts b/src/frontend-vue/src/models/realmSettings.ts index 7b995bca..9a0ccf97 100644 --- a/src/frontend-vue/src/models/realmSettings.ts +++ b/src/frontend-vue/src/models/realmSettings.ts @@ -281,6 +281,8 @@ export interface SignInPolicyDto { Passkey: boolean Totp: boolean EmailAfterPassword: boolean + /** Realm-only: hours after a password reset by e-mail before the e-mail code stands in for a user's own second factor. */ + EmailFallbackWaitHours: number OwnFactorNotOffered: OwnFactorNotOffered } @@ -296,6 +298,7 @@ export const SIGN_IN_POLICY_DEFAULTS: SignInPolicyDto = { Passkey: true, Totp: true, EmailAfterPassword: true, + EmailFallbackWaitHours: 24, OwnFactorNotOffered: 'RequireViaBrowser', } diff --git a/src/frontend-vue/src/views/admin/RealmSettingsView.vue b/src/frontend-vue/src/views/admin/RealmSettingsView.vue index dbfc43cb..554fec04 100644 --- a/src/frontend-vue/src/views/admin/RealmSettingsView.vue +++ b/src/frontend-vue/src/views/admin/RealmSettingsView.vue @@ -1278,6 +1278,11 @@ async function rotateSigningKey() { :hint="t('admin.signIn.ownFactorNotOfferedHint', {}, 'What happens when a user turned on a second factor (e.g. an authenticator app) that an app does not offer.')"> + + + {{ t('admin.signIn.ownFactorIgnoreNotice', {}, 'Users who set up their own second factor (e.g. an authenticator app) are not asked for it when signing in to this app, unless the app offers it.') }}