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

@@ -292,12 +292,19 @@ namespace PrivaPub.Controllers.ClientToServer
return default;
}
// This device signs out: its session (every token it renewed) is refused from now on. A token from before sessions had
// ids can't be told apart from the others, so it signs every device out.
[HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)]
public IActionResult Logout()
public async Task<IActionResult> Logout([FromServices] IRootSessions sessions, CancellationToken token)
{
var result = new WebResult();
try
{
var sessionId = User.FindFirst(AuthTokenManager.SessionId)?.Value;
if (string.IsNullOrEmpty(sessionId))
await sessions.Revoke(User.GetUserId(), token);
else
await sessions.End(User.GetUserId(), sessionId, token);
return Ok();
}
catch (Exception ex)
@@ -307,27 +314,63 @@ namespace PrivaPub.Controllers.ClientToServer
}
}
[HttpPost, Route("/clientapi/user/update"), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> UpdateUser(UserForm userEmailForm)
[HttpPost, Route("/clientapi/user/logout/everywhere"), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> LogoutEverywhere([FromServices] IRootSessions sessions, CancellationToken token)
{
var result = new WebResult();
if (!ModelState.IsValid)
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
try
{
result = await UsersService.UpdateUserAsync(userEmailForm, User.GetUserId());
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
await sessions.Revoke(User.GetUserId(), token);
return Ok();
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUser)}()");
Logger.LogError(ex, $"{nameof(User)}.{nameof(LogoutEverywhere)}()");
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
}
}
// The address is changed only with the password and a code sent to it (/clientapi/user/email); this older route, which
// took neither and said whether an address was taken, no longer changes anything.
[HttpPost, Route("/clientapi/user/update"), Authorize(Policy = Policies.IsUser)]
public IActionResult UpdateUser(UserForm userEmailForm) =>
StatusCode(StatusCodes.Status422UnprocessableEntity,
new WebResult().Invalidate(Localizer["Changing the address takes your password and a code sent to it."], StatusCodes.Status422UnprocessableEntity));
[HttpGet, Route("/clientapi/user/account"), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> Account()
{
var result = await UsersService.AccountAsync(User.GetUserId());
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
[HttpPost, Route("/clientapi/user/email"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> RequestEmailChange(EmailChangeForm form)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
var result = await UsersService.RequestEmailChangeAsync(User.GetUserId(), form);
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
[HttpPost, Route("/clientapi/user/email/verify"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> VerifyEmail(EmailCodeForm form)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
var result = await UsersService.VerifyEmailAsync(User.GetUserId(), 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)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
var result = await UsersService.RemoveEmailAsync(User.GetUserId(), form.Password);
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
[HttpPost, Route("/clientapi/user/update/settings"), Authorize(Policy = Policies.IsUser)]
public async Task<IActionResult> UpdateUserSettings(ViewUserSettings userSettings)
{
@@ -361,7 +404,9 @@ namespace PrivaPub.Controllers.ClientToServer
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
return Ok();
// every session ended with the change; this device goes on with a new one
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
}
catch (Exception ex)
{
@@ -500,7 +545,7 @@ namespace PrivaPub.Controllers.ClientToServer
if (!result.IsValid)
return StatusCode(result.StatusCode, result);
return Ok(AuthTokenManager.GenerateToken(user, (ViewUserSettings)result.Data));
return Ok(AuthTokenManager.GenerateToken(user, (ViewUserSettings)result.Data, User.FindFirst(AuthTokenManager.SessionId)?.Value));
}
catch (Exception ex)
{
@@ -35,6 +35,7 @@ namespace PrivaPub.Infrastructure.Backup
["Job"] = "work in flight: deliveries and inbox processing belong to the moment",
["Delivery"] = "work in flight, from before jobs",
["EmailRecovery"] = "recovery codes live an hour",
["EmailVerification"] = "email verification codes live an hour",
["openiddict.tokens"] = "sessions: a restore ends every one",
["openiddict.authorizations"] = "sessions: a restore ends every one",
[nameof(MaintenanceLock)] = "who is backing up or restoring now",
+5
View File
@@ -185,6 +185,11 @@ namespace PrivaPub.Infrastructure.Data
await Plain<InteractionEvent>(token, e => e.Host, e => e.At);
await DB.Default.Index<InteractionSalt>().Key(s => s.Day, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
await DB.Default.Index<InteractionSalt>().Key(s => s.ExpiresAt, KeyType.Ascending).Option(o => o.ExpireAfter = TimeSpan.Zero).CreateAsync(token);
// codes live an hour: the TTL monitor removes what expired, nothing else ever has to
await DB.Default.Index<EmailRecovery>().Key(r => r.ExpiresAt, KeyType.Ascending).Option(o => o.ExpireAfter = TimeSpan.Zero).CreateAsync(token);
await Plain<EmailRecovery>(token, r => r.CodeHash);
await DB.Default.Index<EmailVerification>().Key(v => v.ExpiresAt, KeyType.Ascending).Option(o => o.ExpireAfter = TimeSpan.Zero).CreateAsync(token);
await Plain<EmailVerification>(token, v => v.RootUserId);
await DB.Default.Index<InstanceDay>().Key(d => d.Day, KeyType.Ascending).Key(d => d.Host, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
await Plain<InstanceDay>(token, d => d.Host, d => d.Day);
await DB.Default.Index<ServerDay>().Key(d => d.Day, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
@@ -0,0 +1,19 @@
using MongoDB.Entities;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data.Migrations
{
// recovery writes only to a verified address since 2026-10-17; an address a login gave before verification existed is
// taken as verified, or that login would silently lose its way back
public class _019_addresses_given_before_verification_are_kept : IMigration
{
public async Task UpgradeAsync()
{
await DB.Default.Update<RootUser>()
.Match(u => u.Email != null && u.Email != "" && u.DeletedAt == null && !u.IsEmailValidated)
.Modify(u => u.IsEmailValidated, true)
.ExecuteAsync();
}
}
}
@@ -42,7 +42,8 @@ namespace PrivaPub.Middleware
return service
.Configure<MongoSettings>(configuration.GetSection(nameof(MongoSettings)))
.Configure<AppConfiguration>(configuration.GetSection(nameof(AppConfiguration)))
.Configure<PrivaPub.Infrastructure.RegistrationOptions>(configuration.GetSection("Registrations"));
.Configure<PrivaPub.Infrastructure.RegistrationOptions>(configuration.GetSection("Registrations"))
.Configure<HashingOptions>(configuration.GetSection("AppConfiguration:HashingOptions"));//PasswordHasher's; unbound before 2026-10-17
}
public static IServiceCollection PrivaPubFederationConfiguration(this IServiceCollection service, IConfiguration configuration)
{
@@ -111,7 +112,9 @@ namespace PrivaPub.Middleware
.AddSingleton<IInteractionApprovals, InteractionApprovals>()
.AddSingleton<IParticipations, Participations>()
.AddSingleton<IJobHandler, PollRefreshJob>()
.AddSingleton<IMailer, Mailer>()
.AddSingleton<IJobHandler, RecoveryJob>()
.AddSingleton<IJobHandler, EmailVerificationJob>()
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
.AddSingleton<IJobHandler, Domain.Social.FeedSyncJob>()
.AddSingleton<IJobHandler, Federation.Actors.OutboxBackfill>()
+2 -1
View File
@@ -39,7 +39,8 @@ namespace PrivaPub.Models.Jobs
ProcessMedia,
ExportArchive,
ImportArchive,
SyncFeeds
SyncFeeds,
SendEmailVerification
}
public enum JobState
+18
View File
@@ -0,0 +1,18 @@
using MongoDB.Entities;
namespace PrivaPub.Models.User
{
// An address a login asked for, until the code sent to it comes back. EmailVerificationJob makes the code and keeps its
// hash only; the row expires (a TTL index on ExpiresAt), and five wrong codes take it away.
public class EmailVerification : Entity
{
public const int MaxAttempts = 5;
public static readonly TimeSpan Lifetime = TimeSpan.FromHours(1);
public string RootUserId { get; set; }
public string Email { get; set; }
public string CodeHash { get; set; }//null until the job sent the code
public int Attempts { get; set; }
public DateTime ExpiresAt { get; set; }
}
}
+9
View File
@@ -21,6 +21,7 @@ namespace PrivaPub.Models.User
public DateTime? DeletedAt { get; set; }
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
/// <summary>An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user.</summary>
public void SetAdmin(bool admin)
@@ -34,9 +35,17 @@ namespace PrivaPub.Models.User
}
}
// a session that signed out: its tokens carry Sid, and are refused until Until, when they would have expired anyway
public class EndedSession
{
public string Sid { get; set; }
public DateTime Until { get; set; }
}
public class RootUserSettings
{
public string LanguageCode { get; set; } = "en-GB";
public DateTime? SavedAt { get; set; }//when a client last saved them; until then a client keeps its own
public short IconsThemeIndexColour { get; set; } = 25;
public short LightThemeIndexColour { get; set; } = 25;
+7 -24
View File
@@ -1,36 +1,19 @@
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.Models;
using PrivaPub.StaticServices;
namespace PrivaPub.Services
{
// The configuration as appsettings has it now. Builds before 2026-10-17 copied it into Mongo on first boot and read that
// copy forever after, so a changed SMTP account or language list never took; the copy is no longer read.
public class AppConfigurationService
{
public AppConfiguration AppConfiguration;
readonly DbEntities _dbEntities;
AppConfiguration _appConfigurationFromFile;
public AppConfigurationService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
{
_dbEntities = dbEntities;
_appConfigurationFromFile = appConfiguration.CurrentValue;
}
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
public async Task Init()
{
var dbAppConfiguration = await _dbEntities.AppConfiguration.ExecuteFirstAsync();
if (dbAppConfiguration != default)
{
if (AppConfiguration == default || AppConfiguration.LastUpdateDate != dbAppConfiguration.LastUpdateDate)
AppConfiguration = dbAppConfiguration;
return;
}
public AppConfigurationService(IOptionsMonitor<AppConfiguration> appConfiguration) => _appConfiguration = appConfiguration;
dbAppConfiguration = _appConfigurationFromFile;
await DB.Default.SaveAsync(dbAppConfiguration);
AppConfiguration = dbAppConfiguration;
}
public AppConfiguration AppConfiguration => _appConfiguration.CurrentValue;
public Task Init() => Task.CompletedTask;
}
}
+73
View File
@@ -0,0 +1,73 @@
using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using System.Security.Cryptography;
using System.Text.Json;
namespace PrivaPub.Services
{
public sealed record EmailVerificationPayload(string VerificationId);
// Sends the code that proves a login owns the address it asked for. The code exists only in the email; the row keeps its
// hash. An address another login has verified meanwhile gets nothing, so the answer to the request never told whether
// the address was taken, and no stranger's inbox receives a code it didn't ask for twice.
public class EmailVerificationJob : IJobHandler
{
public const string Host = "email";
readonly IMailer _mailer;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<EmailVerificationJob> _logger;
public EmailVerificationJob(IMailer mailer, IStringLocalizer<GenericRes> localizer, ILogger<EmailVerificationJob> logger)
{
_mailer = mailer;
_localizer = localizer;
_logger = logger;
}
public JobKind Kind => JobKind.SendEmailVerification;
public int Concurrency => 1;
public int MaxAttempts => 3;
public int PerHostLimit => 1;
public static string NewCode() => RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var payload = JsonSerializer.Deserialize<EmailVerificationPayload>(job.Payload);
var now = DateTime.UtcNow;
var pending = string.IsNullOrEmpty(payload?.VerificationId) ? default
: await DB.Default.Find<EmailVerification>().Match(v => v.ID == payload.VerificationId && v.ExpiresAt > now).ExecuteFirstAsync(token);
if (pending == default)
return JobOutcome.Done;
var user = await DB.Default.Find<RootUser>().MatchID(pending.RootUserId).ExecuteFirstAsync(token);
if (user is not { DeletedAt: null, IsBanned: false })
return JobOutcome.Done;
if (await DB.Default.Find<RootUser>().Match(u => u.Email == pending.Email && u.IsEmailValidated && u.ID != user.ID && u.DeletedAt == null).ExecuteAnyAsync(token))
return JobOutcome.Done;
var code = NewCode();
await DB.Default.Update<EmailVerification>().MatchID(pending.ID).Modify(v => v.CodeHash, RecoveryJob.Hash(code)).ExecuteAsync(token);
try
{
await _mailer.Send(pending.Email, _localizer["PrivaPub - Confirm your email address"],
string.Format(_localizer[
"Hello,\n\nThe PrivaPub login {0} asked to use this address. If it was you, enter this code within an hour:\n\n{1}\n\nIf it was not you, ignore this email: the address is not used."],
user.UserName, code), token);
return JobOutcome.Done;
}
catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested)
{
_logger.LogWarning(ex, "The verification email could not be sent");
return JobOutcome.Retry("smtp");
}
}
}
}
+8 -2
View File
@@ -18,8 +18,6 @@ namespace PrivaPub.Services
Task<WebResult> RemoveUserAsync(UsersIds usersIds);
Task<WebResult> RemoveSelfAsync(string rootId, string password);
Task<WebResult> UpdateUserAsync(UserForm userEmailForm, string userId);
Task<WebResult> UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId);
Task<WebResult> GetUserSettingsAsync(string userId, LoginForm loginForm = default);
@@ -31,5 +29,13 @@ namespace PrivaPub.Services
Task<WebResult> IsValidRecoveryCode(string recoveryCode);
Task<WebResult> ChangePassword(NewPasswordForm newPasswordForm);
Task<WebResult> AccountAsync(string rootId);
Task<WebResult> RequestEmailChangeAsync(string rootId, EmailChangeForm form);
Task<WebResult> VerifyEmailAsync(string rootId, string code);
Task<WebResult> RemoveEmailAsync(string rootId, string password);
}
}
+41
View File
@@ -0,0 +1,41 @@
using MailKit.Net.Smtp;
using Microsoft.Extensions.Options;
using MimeKit;
using PrivaPub.Models;
namespace PrivaPub.Services
{
public interface IMailer
{
/// <summary>Sends a plain-text email; throws when SMTP fails, so the job that sends it retries.</summary>
Task Send(string to, string subject, string body, CancellationToken token);
}
// The server's one way to send an email: through the SMTP account in appsettings (AppConfiguration:EmailConfiguration),
// read as it is now. Only jobs call it, so nobody waits on SMTP.
public class Mailer : IMailer
{
readonly IOptionsMonitor<AppConfiguration> _app;
public Mailer(IOptionsMonitor<AppConfiguration> app) => _app = app;
public async Task Send(string to, string subject, string body, CancellationToken token)
{
var email = _app.CurrentValue.EmailConfiguration;
var message = new MimeMessage();
message.From.Add(new MailboxAddress("PrivaPub", email.SmtpUsername));
message.To.Add(MailboxAddress.Parse(to));
message.Subject = subject;
message.Body = new TextPart("plain") { Text = body };
using var smtp = new SmtpClient();
await smtp.ConnectAsync(email.SmtpServer, email.SmtpPort, email.UseSSL, token);
if (!string.IsNullOrEmpty(email.SmtpPassword))
await smtp.AuthenticateAsync(email.SmtpUsername, email.SmtpPassword, token);
await smtp.SendAsync(message, token);
await smtp.DisconnectAsync(quit: true, token);
}
}
}
+9 -26
View File
@@ -1,9 +1,5 @@
using MailKit.Net.Smtp;
using Microsoft.Extensions.Localization;
using MimeKit;
using MongoDB.Entities;
using PrivaPub.Infrastructure.Jobs;
@@ -21,19 +17,19 @@ namespace PrivaPub.Services
// Sends a password recovery link. Every request queues one of these, for an account that exists or not, so the answer
// to the request and its timing say nothing; SMTP's slowness and failures happen here, where nobody waits on them.
// The code exists only in the email: the database keeps its hash, for an hour.
// The code exists only in the email: the database keeps its hash, for an hour. Only a verified address is written to.
public class RecoveryJob : IJobHandler
{
public const string Host = "recovery";
public static readonly TimeSpan CodeLifetime = TimeSpan.FromHours(1);
readonly AppConfigurationService _app;
readonly IMailer _mailer;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<RecoveryJob> _logger;
public RecoveryJob(AppConfigurationService app, IStringLocalizer<GenericRes> localizer, ILogger<RecoveryJob> logger)
public RecoveryJob(IMailer mailer, IStringLocalizer<GenericRes> localizer, ILogger<RecoveryJob> logger)
{
_app = app;
_mailer = mailer;
_localizer = localizer;
_logger = logger;
}
@@ -51,31 +47,18 @@ namespace PrivaPub.Services
if (string.IsNullOrEmpty(payload?.RootUserId))
return JobOutcome.Done;//asked for an account that does not exist: there is nobody to write to
var user = await DB.Default.Find<RootUser>().MatchID(payload.RootUserId).ExecuteFirstAsync(token);
if (user is not { DeletedAt: null, IsBanned: false } || string.IsNullOrEmpty(user.Email))
if (user is not { DeletedAt: null, IsBanned: false, IsEmailValidated: true } || string.IsNullOrEmpty(user.Email))
return JobOutcome.Done;
var code = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == user.ID);
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = user.ID, CodeHash = Hash(code), ExpiresAt = DateTime.UtcNow + CodeLifetime }, token);
var email = _app.AppConfiguration.EmailConfiguration;
var message = new MimeMessage();
message.From.Add(new MailboxAddress("PrivaPub", email.SmtpUsername));
message.To.Add(MailboxAddress.Parse(user.Email));
message.Subject = _localizer["PrivaPub - Password recovery link"];
message.Body = new TextPart("plain")
{
Text = string.Format(_localizer[
"Hello,\n\nSomeone asked to reset the password of the PrivaPub login {0}. If it was you, open this link within an hour:\n{1}\n\nIf it was not you, ignore this email: nothing changes."],
user.UserName, $"{payload.Host}/password-recovery?rc={code}")
};
try
{
using var smtp = new SmtpClient();
await smtp.ConnectAsync(email.SmtpServer, email.SmtpPort, email.UseSSL, token);
await smtp.AuthenticateAsync(email.SmtpUsername, email.SmtpPassword, token);
await smtp.SendAsync(message, token);
await smtp.DisconnectAsync(quit: true, token);
await _mailer.Send(user.Email, _localizer["PrivaPub - Password recovery link"],
string.Format(_localizer[
"Hello,\n\nSomeone asked to reset the password of the PrivaPub login {0}. If it was you, open this link within an hour:\n{1}\n\nIf it was not you, ignore this email: nothing changes."],
user.UserName, $"{payload.Host}/password-recovery?rc={code}"), token);
return JobOutcome.Done;
}
catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested)
+3
View File
@@ -33,6 +33,9 @@ namespace PrivaPub.Services
return "The account can no longer be used.";
if ((root.SessionStamp ?? string.Empty) != (principal.FindFirst(AuthTokenManager.SessionStamp)?.Value ?? string.Empty))
return "The account's sessions were ended.";
var sessionId = principal.FindFirst(AuthTokenManager.SessionId)?.Value;
if (!string.IsNullOrEmpty(sessionId) && root.EndedSessions.Any(s => s.Sid == sessionId))
return "The session was signed out.";
return default;
}
+21 -3
View File
@@ -1,3 +1,4 @@
using MongoDB.Driver;
using MongoDB.Entities;
using OpenIddict.Abstractions;
@@ -10,22 +11,26 @@ namespace PrivaPub.Services
public interface IRootSessions
{
Task Revoke(string rootId, CancellationToken token);
Task End(string rootId, string sessionId, CancellationToken token);
}
// Ends everything a root is signed in with: its /clientapi tokens, through a new SessionStamp (checked by JwtEvents),
// and the Mastodon API tokens and authorizations of each of its personas. Used when its password is recovered and when
// it is deleted.
// and the Mastodon API tokens and authorizations of each of its personas. Used when its password is changed or
// recovered, when it signs out everywhere and when it is deleted. End signs out one session only: the device that
// asked (its Mastodon tokens it revokes itself).
public class RootSessions : IRootSessions
{
readonly DbEntities _dbEntities;
readonly IOpenIddictTokenManager _tokens;
readonly IOpenIddictAuthorizationManager _authorizations;
readonly AuthTokenManager _authTokens;
public RootSessions(DbEntities dbEntities, IOpenIddictTokenManager tokens, IOpenIddictAuthorizationManager authorizations)
public RootSessions(DbEntities dbEntities, IOpenIddictTokenManager tokens, IOpenIddictAuthorizationManager authorizations, AuthTokenManager authTokens)
{
_dbEntities = dbEntities;
_tokens = tokens;
_authorizations = authorizations;
_authTokens = authTokens;
}
public async Task Revoke(string rootId, CancellationToken token)
@@ -33,6 +38,7 @@ namespace PrivaPub.Services
await DB.Default.Update<RootUser>().MatchID(rootId)
.Modify(u => u.CredentialsChangedAt, DateTime.UtcNow)
.Modify(u => u.SessionStamp, Guid.NewGuid().ToString("N"))
.Modify(u => u.EndedSessions, new List<EndedSession>())//the new stamp ends those too
.ExecuteAsync(token);
foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId).ExecuteAsync(token))
{
@@ -40,5 +46,17 @@ namespace PrivaPub.Services
await _authorizations.RevokeBySubjectAsync(link.AvatarId, token);
}
}
// remembered as long as one of its tokens could still be valid; the ones that have expired are forgotten meanwhile
public async Task End(string rootId, string sessionId, CancellationToken token)
{
var now = DateTime.UtcNow;
await DB.Default.Update<RootUser>().MatchID(rootId)
.Modify(b => b.PullFilter(u => u.EndedSessions, s => s.Until < now))
.ExecuteAsync(token);
await DB.Default.Update<RootUser>().MatchID(rootId)
.Modify(b => b.Push(u => u.EndedSessions, new EndedSession { Sid = sessionId, Until = now + _authTokens.Lifetime }))
.ExecuteAsync(token);
}
}
}
+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);
}
}
}
}
+11 -4
View File
@@ -14,6 +14,9 @@ namespace PrivaPub.StaticServices
{
public const string SessionStamp = "ss";
const string SessionStampClaim = SessionStamp;//the root's RootUser.SessionStamp when issued; JwtEvents refuses a stale one
// one sign-in on one device: its tokens, renewals included, carry the same id, so signing out ends that device alone
// (RootUser.EndedSessions). Not "sid", which JwtBearer maps to another claim type on the way in.
public const string SessionId = "si";
readonly IConfiguration Configuration;
@@ -22,9 +25,12 @@ namespace PrivaPub.StaticServices
Configuration = configuration;
}
public JwtUser GenerateToken(RootUser user, ViewUserSettings userSettings)
public TimeSpan Lifetime => TimeSpan.FromHours(int.Parse(Configuration["AppConfiguration:Jwt:HoursTimeout"]));
/// <summary>A token for the root; a renewal passes the session it renews, a new sign-in gets a new one.</summary>
public JwtUser GenerateToken(RootUser user, ViewUserSettings userSettings, string sessionId = default)
{
var expiration = DateTime.UtcNow.AddHours(int.Parse(Configuration["AppConfiguration:Jwt:HoursTimeout"]));
var expiration = DateTime.UtcNow + Lifetime;
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["AppConfiguration:Jwt:Key"]));
var jwtUser = new JwtUser
{
@@ -39,7 +45,8 @@ namespace PrivaPub.StaticServices
{
new(ClaimTypes.UserData, user.ID),
new(ClaimTypes.Name, user.UserName),
new(SessionStampClaim, user.SessionStamp ?? string.Empty)
new(SessionStampClaim, user.SessionStamp ?? string.Empty),
new(SessionId, string.IsNullOrEmpty(sessionId) ? Guid.NewGuid().ToString("N") : sessionId)
};
claims.Add(new(Policies.IsUser, $"{user.Policies.Contains(Policies.IsUser)}".ToLower()));
@@ -57,4 +64,4 @@ namespace PrivaPub.StaticServices
return jwtUser;
}
}
}
}
+1 -1
View File
@@ -42,7 +42,7 @@
"SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ"
},
"HashingOptions": {
"Iterations": 10101
"Iterations": 210000
},
"Jwt": {
"Key": "bbLOwQyh0jYtJ8Dn6WKCneydLJkqeElqUM03Qwtr7pEMyq/qCZD6v/Sh5Kwrq6YpvJm8GSTHfBiCajUmicfgKw==",
+1 -1
View File
@@ -46,7 +46,7 @@
"SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ"
},
"HashingOptions": {
"Iterations": 10101
"Iterations": 210000
},
"Jwt": {
"Key": "FVkEHw8t84HVXE/+3fyY6gH1AJIlLVNE9UqOzFNBdRKsdFuKrCFBvys0IQfZu+d2Qgg6KtslgntiedPMO98iKg==",