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

@@ -362,6 +362,37 @@ namespace PrivaPub.Controllers.ClientToServer
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
[HttpPost, Route("/clientapi/user/totp/setup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> TotpSetup(PasswordForm form)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
var result = await UsersService.TotpSetupAsync(User.GetUserId(), form.Password);
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
// on with the app's first code; every other session ends, and this device goes on with a new one
[HttpPost, Route("/clientapi/user/totp/enable"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> TotpEnable(TotpCodeForm form)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
var result = await UsersService.TotpEnableAsync(User.GetUserId(), form.Code);
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
var (user, userSettings, codes) = ((RootUser, ViewUserSettings, List<string>))result.Data;
return Ok(new ViewTotpEnabled { RecoveryCodes = codes, Session = AuthTokenManager.GenerateToken(user, userSettings) });
}
[HttpPost, Route("/clientapi/user/totp/disable"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> TotpDisable(TotpDisableForm form)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
var result = await UsersService.TotpDisableAsync(User.GetUserId(), form.Password, form.Code);
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
[HttpPost, Route("/clientapi/user/email/remove"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> RemoveEmail(PasswordForm form)
{
@@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Cli
PrivaPub admin create-root <login> [--admin] the password is read from standard input
PrivaPub admin move-persona <persona> <root> gives a persona to another login; its Mastodon tokens end
PrivaPub admin delete-root <root> deletes a login and every persona it still has
PrivaPub admin reset-totp <root> turns a login's two-factor sign-in off
PrivaPub admin media audit [--fix] media files against what holds them; --fix (as www-data) trashes
what nothing holds and gives today's pictures their rows
PrivaPub admin backup [--kind manual|pre-deploy] [--db-only]
@@ -52,6 +53,8 @@ namespace PrivaPub.Infrastructure.Cli
return await MovePersona(services, persona, root, output);
case ["delete-root", var root]:
return await DeleteRoot(services, root, output);
case ["reset-totp", var root]:
return await ResetTotp(root, output);
case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"):
return await AuditMedia(services, flags.Contains("--fix"), output);
case ["backup", "verify", var id]:
@@ -267,6 +270,22 @@ namespace PrivaPub.Infrastructure.Cli
return 0;
}
// The way back for a login that lost its authenticator and its recovery codes: its two-factor sign-in goes, its
// password stays.
static async Task<int> ResetTotp(string login, TextWriter output)
{
login = login.ToLowerInvariant();
var updated = await DB.Default.Update<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null)
.Modify(u => u.Totp, null).ExecuteAsync();
if (updated.MatchedCount == 0)
{
Console.Error.WriteLine($"no root user '{login}'");
return 1;
}
output.WriteLine($"{login}: two-factor sign-in is off");
return 0;
}
// A login deleted from the box as the client deletes its own (RootRemoval): every persona it still has goes with it,
// announced deleted, and its sessions end.
static async Task<int> DeleteRoot(IServiceProvider services, string login, TextWriter output)
+13
View File
@@ -22,6 +22,7 @@ namespace PrivaPub.Models.User
public DateTime? CredentialsChangedAt { get; set; }//when RootSessions last ended every session
public string SessionStamp { get; set; }//carried by every /clientapi token; a new one ends the tokens issued before
public List<EndedSession> EndedSessions { get; set; } = new();//sessions signed out one by one, until their tokens expire
public RootTotp Totp { get; set; }//two-factor sign-in, when the login set it up (Services/TwoFactor.cs)
/// <summary>An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user.</summary>
public void SetAdmin(bool admin)
@@ -35,6 +36,18 @@ namespace PrivaPub.Models.User
}
}
// TOTP (RFC 6238: 30 s, 6 digits, SHA-1, as every authenticator app reads it). PendingSecret waits for its first code;
// LastStep refuses a code used before; a recovery code is kept as its SHA-256 and works once.
public class RootTotp
{
public string Secret { get; set; }//base32
public DateTime? EnabledAt { get; set; }
public long LastStep { get; set; }
public List<string> RecoveryCodeHashes { get; set; } = new();
public string PendingSecret { get; set; }
public DateTime? PendingSince { get; set; }
}
// a session that signed out: its tokens carry Sid, and are refused until Until, when they would have expired anyway
public class EndedSession
{
+2
View File
@@ -19,6 +19,8 @@
<PackageReference Include="NetVips.Native.linux-x64" Version="8.18.7" />
<PackageReference Include="OpenIddict.AspNetCore" Version="7.7.1" />
<PackageReference Include="OpenIddict.MongoDb" Version="7.7.1" />
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.MongoDB" Version="7.3.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
+6
View File
@@ -37,5 +37,11 @@ namespace PrivaPub.Services
Task<WebResult> VerifyEmailAsync(string rootId, string code);
Task<WebResult> RemoveEmailAsync(string rootId, string password);
Task<WebResult> TotpSetupAsync(string rootId, string password);
Task<WebResult> TotpEnableAsync(string rootId, string code);
Task<WebResult> TotpDisableAsync(string rootId, string password, string code);
}
}
+97 -1
View File
@@ -113,6 +113,14 @@ namespace PrivaPub.Services
return result.Invalidate(Localizer[NoMatch]);
if (user.IsBanned)
return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]);
// only a correct password learns that two-factor is on: it is asked for with a 428
if (user.Totp?.EnabledAt != default)
{
if (string.IsNullOrWhiteSpace(loginForm.OneTimeCode))
return result.Invalidate(Localizer[TwoFactor.CodeNeeded], StatusCodes.Status428PreconditionRequired);
if (!await TwoFactor.Consume(user, loginForm.OneTimeCode, CancellationToken.None))
return result.Invalidate(Localizer[TwoFactor.CodeInvalid]);
}
// hashed again at today's iterations; matched on the old hash, so two sign-ins at once never undo each other
if (needsUpgrade)
@@ -421,7 +429,9 @@ namespace PrivaPub.Services
Email = user.Email,
IsEmailValidated = user.IsEmailValidated,
PendingEmail = pending?.Email,
CreatedAt = user.CreatedAt
CreatedAt = user.CreatedAt,
TwoFactorEnabled = user.Totp?.EnabledAt != default,
RecoveryCodesLeft = user.Totp?.EnabledAt != default ? user.Totp.RecoveryCodeHashes.Count : 0
};
return result;
}
@@ -500,6 +510,92 @@ namespace PrivaPub.Services
}
}
// Two-factor sign-in (TwoFactor): set up with the password, turned on by the app's first code (which also proves the
// app holds the secret), turned off with the password and a code. Turning it on ends every other session.
public async Task<WebResult> TotpSetupAsync(string rootId, string password)
{
var result = new WebResult();
try
{
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty);
if (user?.HashedPassword == default || !verified)
return result.Invalidate(Localizer["Wrong password."], StatusCodes.Status403Forbidden);
if (user.Totp?.EnabledAt != default)
return result.Invalidate(Localizer["Two-factor sign-in is on already: turn it off first."], StatusCodes.Status409Conflict);
var secret = TwoFactor.NewSecret();
await DB.Default.Update<RootUser>().MatchID(rootId)
.Modify(u => u.Totp, new RootTotp { PendingSecret = secret, PendingSince = DateTime.UtcNow })
.ExecuteAsync();
var uri = TwoFactor.OtpAuthUri(secret, user.UserName);
result.Data = new ViewTotpSetup { Secret = secret, OtpAuthUri = uri, QrDataUri = TwoFactor.QrDataUri(uri) };
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpSetupAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> TotpEnableAsync(string rootId, string code)
{
var result = new WebResult();
try
{
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
if (user?.Totp is not { EnabledAt: null, PendingSecret: not null } pending || pending.PendingSince < DateTime.UtcNow.AddHours(-1))
return result.Invalidate(Localizer["Set up two-factor sign-in again: that setup expired."], StatusCodes.Status409Conflict);
if (TwoFactor.Step(pending.PendingSecret, code) is not { } step)
return result.Invalidate(Localizer[TwoFactor.CodeInvalid]);
var codes = TwoFactor.NewRecoveryCodes();
await DB.Default.Update<RootUser>().MatchID(rootId)
.Modify(u => u.Totp, new RootTotp
{
Secret = pending.PendingSecret,
EnabledAt = DateTime.UtcNow,
LastStep = step,
RecoveryCodeHashes = codes.Select(TwoFactor.HashRecovery).ToList()
})
.ExecuteAsync();
await Sessions.Revoke(rootId, CancellationToken.None);
user = await DbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync();
result.Data = (user, ToViewSettings(user.Settings), codes);
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpEnableAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> TotpDisableAsync(string rootId, string password, string code)
{
var result = new WebResult();
try
{
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty);
if (user?.HashedPassword == default || !verified)
return result.Invalidate(Localizer["Wrong password."], StatusCodes.Status403Forbidden);
if (user.Totp?.EnabledAt == default)
return await AccountAsync(rootId);
if (!await TwoFactor.Consume(user, code, CancellationToken.None))
return result.Invalidate(Localizer[TwoFactor.CodeInvalid]);
await DB.Default.Update<RootUser>().MatchID(rootId).Modify(u => u.Totp, null).ExecuteAsync();
return await AccountAsync(rootId);
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpDisableAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> RemoveEmailAsync(string rootId, string password)
{
var result = new WebResult();
+84
View File
@@ -0,0 +1,84 @@
using MongoDB.Entities;
using OtpNet;
using PrivaPub.Models.User;
using QRCoder;
using System.Security.Cryptography;
namespace PrivaPub.Services
{
// Two-factor sign-in by TOTP (completion program R3): the authenticator app's six digits, a step of 30 seconds and one
// step of slack either way, each code taken once (RootTotp.LastStep, moved forward only by a conditional update, so two
// sign-ins with one code can't both pass); or one of ten recovery codes, each kept as its SHA-256 and pulled when used.
public static class TwoFactor
{
public const string CodeNeeded = "Enter the code from your authenticator app, or a recovery code.";
public const string CodeInvalid = "That code is not valid.";
public const string Issuer = "PrivaPub";
const string RecoveryAlphabet = "abcdefghjkmnpqrstuvwxyz23456789";//no 0/o, 1/l/i
public static string NewSecret() => Base32Encoding.ToString(KeyGeneration.GenerateRandomKey(20)).TrimEnd('=');
public static string OtpAuthUri(string secret, string userName) =>
$"otpauth://totp/{Uri.EscapeDataString($"{Issuer}:{userName}")}?secret={secret}&issuer={Issuer}&algorithm=SHA1&digits=6&period=30";
public static string QrDataUri(string text)
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
var svg = new SvgQRCode(data).GetGraphic(6);
return "data:image/svg+xml;base64," + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(svg));
}
public static List<string> NewRecoveryCodes(int count = 10) =>
Enumerable.Range(0, count).Select(_ =>
{
var chars = new char[10];
for (var i = 0; i < chars.Length; i++)
chars[i] = RecoveryAlphabet[RandomNumberGenerator.GetInt32(RecoveryAlphabet.Length)];
return $"{new string(chars, 0, 5)}-{new string(chars, 5, 5)}";
}).ToList();
public static string Normalize(string code) => (code ?? string.Empty).Trim().Replace(" ", "").Replace("-", "").ToLowerInvariant();
public static string HashRecovery(string code) => RecoveryJob.Hash(Normalize(code));
/// <summary>The step a six-digit code matches in the secret's window, or null.</summary>
public static long? Step(string secret, string code)
{
var digits = Normalize(code);
if (digits.Length != 6 || !digits.All(char.IsAsciiDigit) || string.IsNullOrEmpty(secret))
return null;
var totp = new Totp(Base32Encoding.ToBytes(secret));
return totp.VerifyTotp(digits, out var step, VerificationWindow.RfcSpecifiedNetworkDelay) ? step : null;
}
/// <summary>Takes a code for a login with two-factor on: a fresh authenticator code, or an unused recovery code.</summary>
public static async Task<bool> Consume(RootUser user, string code, CancellationToken token)
{
if (user?.Totp is not { EnabledAt: not null } totp)
return false;
if (Step(totp.Secret, code) is { } step)
{
if (step <= totp.LastStep)
return false;//used already
var moved = await DB.Default.Update<RootUser>()
.Match(u => u.ID == user.ID && u.Totp.LastStep < step)
.Modify(u => u.Totp.LastStep, step)
.ExecuteAsync(token);
return moved.ModifiedCount == 1;
}
var hash = HashRecovery(code);
if (!totp.RecoveryCodeHashes.Contains(hash))
return false;
var pulled = await DB.Default.Update<RootUser>()
.Match(u => u.ID == user.ID && u.Totp.RecoveryCodeHashes.Contains(hash))
.Modify(b => b.Pull(u => u.Totp.RecoveryCodeHashes, hash))
.ExecuteAsync(token);
return pulled.ModifiedCount == 1;
}
}
}
+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;