From 1f3bb27e1755351ed66643a128b8d1e5688b6f98 Mon Sep 17 00:00:00 2001
From: thepra
Date: Sat, 17 Oct 2026 03:18:19 +0200
Subject: [PATCH] R3 two-factor sign-in: TOTP with recovery codes, asked for
only after a correct password
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 : 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
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
---
CLAUDE.md | 10 +-
PrivaPub.ClientModels/LoginForm.cs | 5 +
PrivaPub.ClientModels/User/AccountForms.cs | 41 ++++++++
.../ClientToServer/RootUserController.cs | 31 ++++++
PrivaPub/Infrastructure/Cli/AdminCommands.cs | 19 ++++
PrivaPub/Models/User/RootUser.cs | 13 +++
PrivaPub/PrivaPub.csproj | 2 +
PrivaPub/Services/IRootUsersService.cs | 6 ++
PrivaPub/Services/RootUsersService.cs | 98 ++++++++++++++++++-
PrivaPub/Services/TwoFactor.cs | 84 ++++++++++++++++
PrivaPub/Web/Pages/OAuth/Login.cshtml | 1 +
PrivaPub/Web/Pages/OAuth/OAuthPages.cs | 11 ++-
tools/pasture/scenarios/accounts.sh | 56 +++++++++++
13 files changed, 372 insertions(+), 5 deletions(-)
create mode 100644 PrivaPub/Services/TwoFactor.cs
diff --git a/CLAUDE.md b/CLAUDE.md
index 788fdb1..5ebe2d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -89,7 +89,7 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
- Cli/ AdminCommands (`PrivaPub admin promote|demote|create-root|move-persona|delete-root`,
+ Cli/ AdminCommands (`PrivaPub admin promote|demote|create-root|move-persona|delete-root|reset-totp`,
`media audit`, `backup`, `backups`, `restore`)
Backup/ ServerBackup, ServerRestore, ProtectiveMerge, MaintenanceLock, TransferStore
Geo/ GeoLocator, GeoUpdater (DB-IP Lite), CdnCatalog, CdnUpdater
@@ -185,6 +185,7 @@ echo '' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-ro
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin move-persona # its link moves, its Mastodon tokens end
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin delete-root # as the client deletes a login: its personas go too
+ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin reset-totp # a login that lost its authenticator and codes
```
The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through
@@ -420,6 +421,13 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
- Sign-up still says a login name is taken: uniqueness has to be told, and sign-up is reachable only while setup
waits or with an invitation, rate-limited.
- `AppConfigurationService` reads appsettings as they are now; the copy older builds saved in Mongo is not read.
+ - **Two-factor sign-in** (R3, `Services/TwoFactor.cs`, `RootUser.Totp`): TOTP (30 s, 6 digits, SHA-1, one step of
+ slack, Otp.NET), set up with the password (`/clientapi/user/totp/setup`: the secret, its `otpauth://` URI and a QR
+ code as an SVG data URI, QRCoder), turned on by the app's first code (`/totp/enable`: ten recovery codes shown
+ once, every other session ended) and off with the password and a code (`/totp/disable`). Each code is taken once
+ (`LastStep`, moved by a conditional update); a recovery code is kept as its SHA-256 and pulled when used. Sign-in
+ (`/clientapi/user/login`, the invitation login and `/oauth/login`) asks for `OneTimeCode` with a 428, and only after a
+ correct password. Recovery by email leaves two-factor on; `PrivaPub admin reset-totp ` turns it off.
- **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete`
with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members
and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer
diff --git a/PrivaPub.ClientModels/LoginForm.cs b/PrivaPub.ClientModels/LoginForm.cs
index e3fbc5e..49f9749 100644
--- a/PrivaPub.ClientModels/LoginForm.cs
+++ b/PrivaPub.ClientModels/LoginForm.cs
@@ -34,5 +34,10 @@ namespace PrivaPub.ClientModels
public bool ThemeIsDarkMode { get; set; } = false;
public string InvitationPassword { get; set; }
+
+ // the authenticator app's code, or one of the recovery codes, for a login with two-factor sign-in; asked for with a
+ // 428 once the password was right
+ [StringLength(32, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
+ public string OneTimeCode { get; set; }
}
}
\ No newline at end of file
diff --git a/PrivaPub.ClientModels/User/AccountForms.cs b/PrivaPub.ClientModels/User/AccountForms.cs
index 9beef18..e347a85 100644
--- a/PrivaPub.ClientModels/User/AccountForms.cs
+++ b/PrivaPub.ClientModels/User/AccountForms.cs
@@ -49,5 +49,46 @@ namespace PrivaPub.ClientModels.User
public bool IsEmailValidated { get; set; } = false;
public string PendingEmail { get; set; }//waiting for its code
public DateTime CreatedAt { get; set; }
+ public bool TwoFactorEnabled { get; set; } = false;
+ public int RecoveryCodesLeft { get; set; } = 0;
+ }
+
+ // a code from the authenticator app (six digits) or a recovery code
+ public class TotpCodeForm
+ {
+ [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
+ StringLength(32, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
+ Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
+ public string Code { get; set; }
+ }
+
+ public class TotpDisableForm
+ {
+ [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
+ DataType(DataType.Password),
+ PasswordPropertyText(true),
+ StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
+ Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
+ public string Password { get; set; }
+
+ [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
+ StringLength(32, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
+ Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
+ public string Code { get; set; }
+ }
+
+ // what an authenticator app needs: the secret, as text and as the otpauth:// URI its QR code holds
+ public class ViewTotpSetup
+ {
+ public string Secret { get; set; }
+ public string OtpAuthUri { get; set; }
+ public string QrDataUri { get; set; }//an SVG image, as a data: URI
+ }
+
+ // shown once: the recovery codes, and this device's new session (turning two-factor on ends every other one)
+ public class ViewTotpEnabled
+ {
+ public List RecoveryCodes { get; set; } = [];
+ public JwtUser Session { get; set; }
}
}
diff --git a/PrivaPub/Controllers/ClientToServer/RootUserController.cs b/PrivaPub/Controllers/ClientToServer/RootUserController.cs
index 0d7e087..26f271e 100644
--- a/PrivaPub/Controllers/ClientToServer/RootUserController.cs
+++ b/PrivaPub/Controllers/ClientToServer/RootUserController.cs
@@ -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 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 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))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 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 RemoveEmail(PasswordForm form)
{
diff --git a/PrivaPub/Infrastructure/Cli/AdminCommands.cs b/PrivaPub/Infrastructure/Cli/AdminCommands.cs
index b384cc5..f9edb71 100644
--- a/PrivaPub/Infrastructure/Cli/AdminCommands.cs
+++ b/PrivaPub/Infrastructure/Cli/AdminCommands.cs
@@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Cli
PrivaPub admin create-root [--admin] the password is read from standard input
PrivaPub admin move-persona gives a persona to another login; its Mastodon tokens end
PrivaPub admin delete-root deletes a login and every persona it still has
+ PrivaPub admin reset-totp 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 ResetTotp(string login, TextWriter output)
+ {
+ login = login.ToLowerInvariant();
+ var updated = await DB.Default.Update().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 DeleteRoot(IServiceProvider services, string login, TextWriter output)
diff --git a/PrivaPub/Models/User/RootUser.cs b/PrivaPub/Models/User/RootUser.cs
index 2d7b3bc..9f66368 100644
--- a/PrivaPub/Models/User/RootUser.cs
+++ b/PrivaPub/Models/User/RootUser.cs
@@ -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 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)
/// An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user.
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 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
{
diff --git a/PrivaPub/PrivaPub.csproj b/PrivaPub/PrivaPub.csproj
index 59d71ad..34cc923 100644
--- a/PrivaPub/PrivaPub.csproj
+++ b/PrivaPub/PrivaPub.csproj
@@ -19,6 +19,8 @@
+
+
diff --git a/PrivaPub/Services/IRootUsersService.cs b/PrivaPub/Services/IRootUsersService.cs
index 3bc3e59..c323d8e 100644
--- a/PrivaPub/Services/IRootUsersService.cs
+++ b/PrivaPub/Services/IRootUsersService.cs
@@ -37,5 +37,11 @@ namespace PrivaPub.Services
Task VerifyEmailAsync(string rootId, string code);
Task RemoveEmailAsync(string rootId, string password);
+
+ Task TotpSetupAsync(string rootId, string password);
+
+ Task TotpEnableAsync(string rootId, string code);
+
+ Task TotpDisableAsync(string rootId, string password, string code);
}
}
\ No newline at end of file
diff --git a/PrivaPub/Services/RootUsersService.cs b/PrivaPub/Services/RootUsersService.cs
index 1aded5f..f06b524 100644
--- a/PrivaPub/Services/RootUsersService.cs
+++ b/PrivaPub/Services/RootUsersService.cs
@@ -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 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().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 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().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 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().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 RemoveEmailAsync(string rootId, string password)
{
var result = new WebResult();
diff --git a/PrivaPub/Services/TwoFactor.cs b/PrivaPub/Services/TwoFactor.cs
new file mode 100644
index 0000000..b60c7fa
--- /dev/null
+++ b/PrivaPub/Services/TwoFactor.cs
@@ -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 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));
+
+ /// The step a six-digit code matches in the secret's window, or null.
+ 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;
+ }
+
+ /// Takes a code for a login with two-factor on: a fresh authenticator code, or an unused recovery code.
+ public static async Task 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()
+ .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()
+ .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;
+ }
+ }
+}
diff --git a/PrivaPub/Web/Pages/OAuth/Login.cshtml b/PrivaPub/Web/Pages/OAuth/Login.cshtml
index 49cfb6f..149bdf4 100644
--- a/PrivaPub/Web/Pages/OAuth/Login.cshtml
+++ b/PrivaPub/Web/Pages/OAuth/Login.cshtml
@@ -10,6 +10,7 @@ application never sees the login.
+
@if (Model.Error != default)
{
@Model.Error
diff --git a/PrivaPub/Web/Pages/OAuth/OAuthPages.cs b/PrivaPub/Web/Pages/OAuth/OAuthPages.cs
index 94ccd91..b28e33d 100644
--- a/PrivaPub/Web/Pages/OAuth/OAuthPages.cs
+++ b/PrivaPub/Web/Pages/OAuth/OAuthPages.cs
@@ -40,7 +40,7 @@ namespace PrivaPub.Web.Pages.OAuth
public void OnGet() => Harden();
- public async Task OnPostAsync([FromForm] string userName, [FromForm] string password)
+ public async Task 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;
diff --git a/tools/pasture/scenarios/accounts.sh b/tools/pasture/scenarios/accounts.sh
index 365bc51..d43f996 100644
--- a/tools/pasture/scenarios/accounts.sh
+++ b/tools/pasture/scenarios/accounts.sh
@@ -96,3 +96,59 @@ iterations=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db
[ "$iterations" != 10101 ] && ok "it was made again at $iterations iterations" || ko "the old hash was kept"
ttl=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.EmailRecovery.getIndexes().some(i => i.expireAfterSeconds === 0) && db.EmailVerification.getIndexes().some(i => i.expireAfterSeconds === 0))")
[ "$ttl" = true ] && ok "recovery and verification codes expire by TTL" || ko "no TTL on the codes"
+
+echo " two-factor sign-in"
+# totp [step offset]: the authenticator's code now (or a step later)
+totp() { python3 -c "
+import base64,hmac,hashlib,struct,time,sys
+s=sys.argv[1]; s+='='*((8-len(s)%8)%8)
+k=base64.b32decode(s.upper())
+t=int(time.time())//30+int(sys.argv[2])
+h=hmac.new(k,struct.pack('>Q',t),hashlib.sha1).digest()
+o=h[-1]&15
+print('%06d'%((struct.unpack('>I',h[o:o+4])[0]&0x7fffffff)%1000000))" "$1" "${2:-0}"; }
+login2() { curl -s -o /dev/null -w '%{http_code}' -X POST "$P/clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\"$1\",\"password\":\"$2\",\"oneTimeCode\":\"$3\"}"; }
+T=$(login "$user" "$pw")
+[ "$(code_of "$T" POST /clientapi/user/totp/setup -d '{"password":"wrong-Pass-9"}')" = 403 ] && ok "setting it up takes the password" || ko "a wrong password set it up"
+setup=$(api "$T" POST /clientapi/user/totp/setup -d "{\"password\":\"$pw\"}")
+secret=$(echo "$setup" | j "print(d['secret'])")
+[ -n "$secret" ] && echo "$setup" | j "print(d['otpAuthUri'].startswith('otpauth://totp/') and d['qrDataUri'].startswith('data:image/svg+xml;base64,'))" | grep -q True \
+ && ok "the setup gives the secret, its URI and a QR code" || ko "the setup is incomplete"
+[ "$(code_of "$T" POST /clientapi/user/totp/enable -d '{"code":"000000"}')" = 400 ] && ok "a wrong first code is refused" || ko "a wrong first code turned it on"
+enabled=$(api "$T" POST /clientapi/user/totp/enable -d "{\"code\":\"$(totp "$secret")\"}")
+recovery=$(echo "$enabled" | j "print(d['recoveryCodes'][0])"); spare=$(echo "$enabled" | j "print(d['recoveryCodes'][1])")
+T2=$(echo "$enabled" | j "print(d['session']['token'])")
+[ "$(echo "$enabled" | j "print(len(d['recoveryCodes']))")" = 10 ] && ok "turning it on shows ten recovery codes" || ko "no recovery codes"
+[ "$(code_of "$T" GET /clientapi/user/account)" = 401 ] && [ "$(code_of "$T2" GET /clientapi/user/account)" = 200 ] \
+ && ok "turning it on ends the other sessions and this device goes on" || ko "turning it on left the sessions as they were"
+[ "$(login2 "$user" "$pw" "")" = 428 ] && ok "a correct password alone is asked for a code (428)" || ko "a password alone still signs in"
+[ "$(login2 "$user" "wrong-Pass-9" "")" = 400 ] && ok "a wrong password is not told about two-factor" || ko "a wrong password learns of two-factor"
+[ "$(login2 "$user" "$pw" "$(totp "$secret")")" = 400 ] && ok "the code that turned it on can't be used again" || ko "a used code signed in"
+next=$(totp "$secret" 1)
+[ "$(login2 "$user" "$pw" "$next")" = 200 ] && ok "the next code signs in" || ko "the next code was refused"
+[ "$(login2 "$user" "$pw" "$next")" = 400 ] && ok "a code is taken once" || ko "a code signed in twice"
+[ "$(login2 "$user" "$pw" "$recovery")" = 200 ] && ok "a recovery code signs in" || ko "a recovery code was refused"
+[ "$(login2 "$user" "$pw" "$recovery")" = 400 ] && ok "a recovery code works once" || ko "a recovery code worked twice"
+[ "$(api "$T2" GET /clientapi/user/account | j "print(d['twoFactorEnabled'] and d['recoveryCodesLeft'])")" = 9 ] \
+ && ok "nine recovery codes are left" || ko "the recovery codes left are miscounted"
+# oauth_login [code]: the OAuth sign-in page's answer (with its antiforgery token, as tools/smoke/oauth.sh)
+oauth_login() {
+ local jar="$work/oauth-jar" xt
+ rm -f "$jar"
+ xt=$(curl -s -c "$jar" -b "$jar" "$P/oauth/login" | grep -o 'name="__RequestVerificationToken" type="hidden" value="[^"]*"' | sed 's/.*value="//;s/"//')
+ curl -s -o "$work/oauth-page" -w '%{http_code}' -c "$jar" -b "$jar" -X POST "$P/oauth/login" --data-urlencode "__RequestVerificationToken=$xt" \
+ --data-urlencode "userName=$1" --data-urlencode "password=$2" --data-urlencode "oneTimeCode=${3:-}"
+}
+oauth=$(oauth_login "$user" "$pw")
+[ "$oauth" = 200 ] && grep -q 'authenticator app' "$work/oauth-page" && ok "the OAuth sign-in page asks for the code" || ko "the OAuth sign-in page answered $oauth without a code"
+oauth=$(oauth_login "$user" "$pw" "$spare")
+[ "$oauth" = 302 ] && ok "the OAuth sign-in page takes a code" || ko "the OAuth sign-in page answered $oauth with a code"
+[ "$(code_of "$T2" POST /clientapi/user/totp/disable -d "{\"password\":\"$pw\",\"code\":\"000000\"}")" = 400 ] \
+ && ok "turning it off takes a code" || ko "a wrong code turned it off"
+off=$(api "$T2" POST /clientapi/user/totp/disable -d "{\"password\":\"$pw\",\"code\":\"$(echo "$enabled" | j "print(d['recoveryCodes'][2])")\"}")
+[ "$(echo "$off" | j "print(d['twoFactorEnabled'])")" = False ] && [ "$(login2 "$user" "$pw" "")" = 200 ] \
+ && ok "turned off, the password alone signs in again" || ko "two-factor could not be turned off"
+setup=$(api "$T2" POST /clientapi/user/totp/setup -d "{\"password\":\"$pw\"}"); secret=$(echo "$setup" | j "print(d['secret'])")
+T3=$(api "$T2" POST /clientapi/user/totp/enable -d "{\"code\":\"$(totp "$secret")\"}" | j "print(d['session']['token'])")
+podman exec -w /app pasture-privapub /app/PrivaPub admin reset-totp "$user" >/dev/null 2>&1
+[ "$(login2 "$user" "$pw" "")" = 200 ] && ok "admin reset-totp turns it off from the box" || ko "admin reset-totp did not turn it off"