R2 accounts are safe: email by password and code, recovery only to it, sessions that end
Build / Build (push) Successful in 26s

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

- An email address is set only with the password and a 6-digit code sent to it: /clientapi/user/email, /email/verify,
  /email/remove and GET /clientapi/user/account (EmailVerification: an hour, five tries, the code's hash only;
  EmailVerificationJob through the new IMailer). The request answers one sentence whether or not the address is free,
  and nothing goes to an address another login verified. The old POST /clientapi/user/update, which changed the
  address with neither and said when one was taken, answers 422.
- Recovery writes only to a verified address; migration _019 keeps the addresses given before verification existed.
- A changed password ends every session and every persona token, and answers a new JWT for the device that changed it.
- Sessions: every JWT carries its session's id ("si", kept by sniff/again). GET /clientapi/user/logout, which did
  nothing, ends that session alone (RootUser.EndedSessions, until its tokens would expire); POST
  /clientapi/user/logout/everywhere ends them all.
- AppConfiguration:HashingOptions was never bound, so passwords were hashed at the default 9789 iterations: it is
  bound now, at 210,000, and a hash made at other iterations is made again at the next sign-in (matched on the old
  hash, so two sign-ins never undo each other).
- Recovery and verification codes expire through TTL indexes; EmailVerification is never in a backup.
- AppConfigurationService reads appsettings as they are now, not the copy older builds saved in Mongo on first boot.
- ViewUserSettings and RootUserSettings carry SavedAt, so a client signing in keeps its own theme until one saved.
- Pasture: peers/mailpit.sh (Mailpit 1.31.4 as mailpit.test) receives PrivaPub's mail; recovery links open
  https://decepub.test. scenarios/accounts.sh, in sweep batch A, checks all of the above (28 checks).

Verified: dotnet build PrivaPub.sln -c Release; the pasture with gts and mailpit: accounts 28/28 and gts, 91 passed,
0 failed.

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:13:16 +02:00
1 parent ff5e635dda
commit cff75e3a47
27 files changed
+636 -117

No files matched your search

+145 -35
View File
@@ -114,8 +114,16 @@ namespace PrivaPub.Services
if (user.IsBanned)
return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]);
// hashed again at today's iterations; matched on the old hash, so two sign-ins at once never undo each other
if (needsUpgrade)
result.ErrorMessage = Localizer["Needs upgrade!"];
{
var oldHash = user.HashedPassword;
user.HashedPassword = PasswordHasher.Hash(loginForm.Password);
await DB.Default.Update<RootUser>()
.Match(u => u.ID == user.ID && u.HashedPassword == oldHash)
.Modify(u => u.HashedPassword, user.HashedPassword)
.ExecuteAsync();
}
var userSettingsResult = await GetUserSettingsAsync(user.ID, loginForm);
var userSettings = (ViewUserSettings)userSettingsResult.Data;
@@ -131,33 +139,6 @@ namespace PrivaPub.Services
}
}
public async Task<WebResult> UpdateUserAsync(UserForm userForm, string userId)
{
var result = new WebResult();
try
{
var currentUser = await DbEntities.RootUsers.Match(u => u.ID == userId).ExecuteFirstAsync();
if (!string.IsNullOrEmpty(userForm.Email) && currentUser.Email != userForm.Email)
{
var emailAlreadyUsed = await DbEntities.RootUsers.Match(u => u.Email == userForm.Email).ExecuteAnyAsync();
if (emailAlreadyUsed)
return result.Invalidate(Localizer["Email '{0}' already taken.", userForm.Email]);
}
await DB.Default.Update<RootUser>()
.Match(u => u.ID == userId)
.Modify(u => u.Email, userForm.Email)
.ExecuteAsync();
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> UpdateUserSettingsAsync(ViewUserSettings userSettings, string userId)
{
var result = new WebResult();
@@ -184,6 +165,7 @@ namespace PrivaPub.Services
.Modify(u => u.Settings.ThemeIsDarkGray, userSettings.ThemeIsDarkGray)
.Modify(u => u.Settings.ThemeIsLightGray, userSettings.ThemeIsLightGray)
.Modify(u => u.Settings.IconsThemeIndexColour, userSettings.IconsThemeIndexColour)
.Modify(u => u.Settings.SavedAt, DateTime.UtcNow)
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
.ExecuteAsync();
@@ -211,11 +193,14 @@ namespace PrivaPub.Services
if (!verified)
return result.Invalidate(Localizer["Wrong password."]);
var newPasswordHashed = PasswordHasher.Hash(userPasswordForm.NewPassword);
user.HashedPassword = newPasswordHashed;
user.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(user);
await DB.Default.Update<RootUser>().MatchID(user.ID)
.Modify(u => u.HashedPassword, PasswordHasher.Hash(userPasswordForm.NewPassword))
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
.ExecuteAsync();
// whoever else is signed in may be why the password changed: every session ends, and the caller gets a new one
await Sessions.Revoke(user.ID, CancellationToken.None);
user = await DbEntities.RootUsers.MatchID(user.ID).ExecuteFirstAsync();
result.Data = (user, ToViewSettings(user.Settings));
return result;
}
catch (Exception ex)
@@ -343,7 +328,7 @@ namespace PrivaPub.Services
var userName = passwordRecoveryForm.UserName?.ToLowerInvariant();
var user = passwordRecoveryForm.IsEmailDisabled
? await DbEntities.RootUsers.Match(u => u.UserName == userName && u.DeletedAt == null).ExecuteFirstAsync()
: await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.DeletedAt == null).ExecuteFirstAsync();
: await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.IsEmailValidated && u.DeletedAt == null).ExecuteFirstAsync();
await Jobs.Enqueue(JobKind.SendRecovery, JsonSerializer.Serialize(new RecoveryPayload(user?.ID, host)), RecoveryJob.Host,
dedupeKey: default, CancellationToken.None);
result.Data = Localizer[RecoverySent].Value;
@@ -413,7 +398,132 @@ namespace PrivaPub.Services
ThemeIsDarkMode = settings.ThemeIsDarkMode,
IconsThemeIndexColour = settings.IconsThemeIndexColour,
ThemeIsDarkGray = settings.ThemeIsDarkGray,
ThemeIsLightGray = settings.ThemeIsLightGray
ThemeIsLightGray = settings.ThemeIsLightGray,
SavedAt = settings.SavedAt
};
public const string EmailCodeSent = "If that address can be used, a code is on its way to it. It works for one hour.";
public const string EmailCodeInvalid = "That code is not valid.";
public async Task<WebResult> AccountAsync(string rootId)
{
var result = new WebResult();
try
{
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
if (user == default)
return result.Invalidate(Localizer["User not found."], StatusCodes.Status404NotFound);
var now = DateTime.UtcNow;
var pending = await DB.Default.Find<EmailVerification>().Match(v => v.RootUserId == rootId && v.ExpiresAt > now).ExecuteFirstAsync();
result.Data = new ViewAccount
{
UserName = user.UserName,
Email = user.Email,
IsEmailValidated = user.IsEmailValidated,
PendingEmail = pending?.Email,
CreatedAt = user.CreatedAt
};
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(AccountAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
// Only with the password, so a stolen session can't move recovery to its own inbox. The answer is the same sentence
// whether or not the address is free; EmailVerificationJob sends the code, or nothing when another login holds it.
public async Task<WebResult> RequestEmailChangeAsync(string rootId, EmailChangeForm form)
{
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, form.Password ?? string.Empty);
if (user?.HashedPassword == default || !verified)
return result.Invalidate(Localizer["Wrong password."], StatusCodes.Status403Forbidden);
await DB.Default.DeleteAsync<EmailVerification>(v => v.RootUserId == user.ID);
var pending = new EmailVerification
{
RootUserId = user.ID,
Email = form.Email.Trim().ToLowerInvariant(),
ExpiresAt = DateTime.UtcNow + EmailVerification.Lifetime
};
await DB.Default.SaveAsync(pending);
await Jobs.Enqueue(JobKind.SendEmailVerification, JsonSerializer.Serialize(new EmailVerificationPayload(pending.ID)),
EmailVerificationJob.Host, dedupeKey: default, CancellationToken.None);
result.Data = Localizer[EmailCodeSent].Value;
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RequestEmailChangeAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
// One answer for every failure (wrong, expired, five tries spent, or the address verified by another login meanwhile)
public async Task<WebResult> VerifyEmailAsync(string rootId, string code)
{
var result = new WebResult();
try
{
var now = DateTime.UtcNow;
var pending = await DB.Default.Find<EmailVerification>().Match(v => v.RootUserId == rootId && v.ExpiresAt > now).ExecuteFirstAsync();
if (pending == default || string.IsNullOrEmpty(pending.CodeHash))
return result.Invalidate(Localizer[EmailCodeInvalid]);
if (pending.CodeHash != RecoveryJob.Hash(code?.Trim()))
{
if (pending.Attempts + 1 >= EmailVerification.MaxAttempts)
await DB.Default.DeleteAsync<EmailVerification>(pending.ID);
else
await DB.Default.Update<EmailVerification>().MatchID(pending.ID).Modify(b => b.Inc(v => v.Attempts, 1)).ExecuteAsync();
return result.Invalidate(Localizer[EmailCodeInvalid]);
}
await DB.Default.DeleteAsync<EmailVerification>(pending.ID);
if (await DbEntities.RootUsers.Match(u => u.Email == pending.Email && u.IsEmailValidated && u.ID != rootId && u.DeletedAt == null).ExecuteAnyAsync())
return result.Invalidate(Localizer[EmailCodeInvalid]);
await DB.Default.Update<RootUser>().MatchID(rootId)
.Modify(u => u.Email, pending.Email)
.Modify(u => u.IsEmailValidated, true)
.Modify(u => u.UpdatedAt, now)
.ExecuteAsync();
return await AccountAsync(rootId);
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(VerifyEmailAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> RemoveEmailAsync(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);
await DB.Default.Update<RootUser>().MatchID(rootId)
.Modify(u => u.Email, null)
.Modify(u => u.IsEmailValidated, false)
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
.ExecuteAsync();
await DB.Default.DeleteAsync<EmailVerification>(v => v.RootUserId == rootId);
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == rootId);
return await AccountAsync(rootId);
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveEmailAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
}
}