R3 two-factor sign-in: TOTP with recovery codes, asked for only after a correct password
Build / Build (push) Successful in 36s

Completion program R3 (docs/ROADMAP.md, 2026-10-17).

- Services/TwoFactor.cs: TOTP (RFC 6238, 30 s, 6 digits, SHA-1, one step of slack, Otp.NET 1.4.1) and ten recovery
  codes (SHA-256 kept, pulled when used). RootUser.Totp holds the secret, the last step taken (moved only by a
  conditional update, so a code works once even for two sign-ins at the same moment) and the codes' hashes.
- /clientapi/user/totp/setup (the password) answers the secret, its otpauth:// URI and a QR code as an SVG data URI
  (QRCoder 1.8.0); /totp/enable takes the app's first code, shows the recovery codes once and ends every other session
  (this device gets a new one); /totp/disable takes the password and a code. GET /clientapi/user/account says whether
  it is on and how many recovery codes are left.
- Sign-in (/clientapi/user/login, the invitation login, /oauth/login) asks for LoginForm.OneTimeCode with a 428, and
  only once the password was right; the OAuth page has the field. Recovery by email leaves two-factor on.
- PrivaPub admin reset-totp <root>: the way back for a login that lost its authenticator and its codes.
- scenarios/accounts.sh checks it all, with codes computed in Python; the OAuth page with its antiforgery token.

Verified: dotnet build PrivaPub.sln -c Release; the pasture (gts, mailpit): accounts 45/45.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 03:18:19 +02:00
1 parent cff75e3a47
commit 1f3bb27e17
13 files changed
+372 -5

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ application never sees the login.</p>
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl">
<p><label>Username<br><input name="UserName" autocomplete="username" required autofocus></label></p>
<p><label>Password<br><input name="Password" type="password" autocomplete="current-password" required></label></p>
<p><label>Code from your authenticator app (only with two-factor sign-in)<br><input name="OneTimeCode" autocomplete="one-time-code" inputmode="text" maxlength="32"></label></p>
@if (Model.Error != default)
{
<p role="alert">@Model.Error</p>
+8 -3
View File
@@ -40,7 +40,7 @@ namespace PrivaPub.Web.Pages.OAuth
public void OnGet() => Harden();
public async Task<IActionResult> OnPostAsync([FromForm] string userName, [FromForm] string password)
public async Task<IActionResult> OnPostAsync([FromForm] string userName, [FromForm] string password, [FromForm] string oneTimeCode)
{
Harden();
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
@@ -48,10 +48,15 @@ namespace PrivaPub.Web.Pages.OAuth
Error = "Enter your username and password.";
return Page();
}
var result = await _users.LoginAsync(new LoginForm { UserName = userName, Password = password });
var result = await _users.LoginAsync(new LoginForm { UserName = userName, Password = password, OneTimeCode = oneTimeCode });
if (result.StatusCode == StatusCodes.Status428PreconditionRequired)
{
Error = "Enter the code from your authenticator app, or a recovery code, with your password.";
return Page();
}
if (!result.IsValid)
{
Error = "That username and password do not match.";
Error = string.IsNullOrEmpty(oneTimeCode) ? "That username and password do not match." : "That username, password and code do not match.";
return Page();
}
var (root, _) = ((RootUser, ClientModels.User.ViewUserSettings))result.Data;