From cff75e3a47a3df00af2f3505c96bec6273796f14 Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 17 Oct 2026 03:13:16 +0200 Subject: [PATCH] R2 accounts are safe: email by password and code, recovery only to it, sessions that end 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 Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof --- CLAUDE.md | 21 +- PrivaPub.ClientModels/User/AccountForms.cs | 53 ++++++ .../User/ViewUserSettings.cs | 3 + .../ClientToServer/RootUserController.cs | 69 +++++-- .../Infrastructure/Backup/ServerBackup.cs | 1 + PrivaPub/Infrastructure/Data/Indexes.cs | 5 + ...sses_given_before_verification_are_kept.cs | 19 ++ .../Middleware/SocialPubConfigurations.cs | 5 +- PrivaPub/Models/Jobs/Job.cs | 3 +- PrivaPub/Models/User/EmailVerification.cs | 18 ++ PrivaPub/Models/User/RootUser.cs | 9 + PrivaPub/Services/AppConfigurationService.cs | 31 +-- PrivaPub/Services/EmailVerificationJob.cs | 73 +++++++ PrivaPub/Services/IRootUsersService.cs | 10 +- PrivaPub/Services/Mailer.cs | 41 ++++ PrivaPub/Services/RecoveryJob.cs | 35 +--- PrivaPub/Services/RootJwt.cs | 3 + PrivaPub/Services/RootSessions.cs | 24 ++- PrivaPub/Services/RootUsersService.cs | 180 ++++++++++++++---- PrivaPub/StaticServices/AuthTokenManager.cs | 15 +- PrivaPub/appsettings.Development.json | 2 +- PrivaPub/appsettings.Production.json | 2 +- tools/pasture/Caddyfile | 5 + tools/pasture/appsettings.Pasture.json | 5 +- tools/pasture/peers/mailpit.sh | 15 ++ tools/pasture/scenarios/accounts.sh | 98 ++++++++++ tools/pasture/sweep.sh | 8 +- 27 files changed, 636 insertions(+), 117 deletions(-) create mode 100644 PrivaPub.ClientModels/User/AccountForms.cs create mode 100644 PrivaPub/Infrastructure/Data/Migrations/_019_addresses_given_before_verification_are_kept.cs create mode 100644 PrivaPub/Models/User/EmailVerification.cs create mode 100644 PrivaPub/Services/EmailVerificationJob.cs create mode 100644 PrivaPub/Services/Mailer.cs create mode 100644 tools/pasture/peers/mailpit.sh create mode 100644 tools/pasture/scenarios/accounts.sh diff --git a/CLAUDE.md b/CLAUDE.md index 7515459..788fdb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -406,6 +406,20 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. queues a `SendRecovery` job, found or not; `RecoveryJob` sends the email and keeps only a SHA-256 of the code, for an hour. A recovered password ends every session of the root (`RootSessions`: a new `SessionStamp`, which every `/clientapi` JWT carries, and OpenIddict revocation for each persona). +- **Accounts and sessions** (completion program R2, 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`; `EmailVerification`, five tries, an hour). The request answers one sentence + whether or not the address is free; `EmailVerificationJob` sends nothing to an address another login verified. + Recovery writes only to a verified address (`IsEmailValidated`; migration `_019` kept the addresses given before). + `POST /clientapi/user/update` no longer changes the address (422). + - A changed password ends every session too, and answers a new JWT for the device that changed it. + - Every JWT carries its session's id (`si`, kept by renewals). `GET /clientapi/user/logout` ends that session alone + (`RootUser.EndedSessions`, until its tokens would expire); `POST /clientapi/user/logout/everywhere` ends them all. + - Passwords are PBKDF2-SHA512 at `AppConfiguration:HashingOptions:Iterations` (210,000; the option was never bound + before 2026-10-17), and a hash made at other iterations is made again at the next sign-in. + - 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. - **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 @@ -528,7 +542,7 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. backup rotates out), copies where a link can't be made. `--db-only` lists them instead. **Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight), - `EmailRecovery`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore + `EmailRecovery` and `EmailVerification`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One backup or restore runs at a time (`MaintenanceLock`, a heartbeat document; a holder silent for 2 minutes is taken over), and the media janitor purges @@ -771,6 +785,11 @@ tools/pasture/run.sh down # removes e announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and has Mastodon leave again, so the town sees no relayed posts. 16 checks. +- **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json` + sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to + `https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the + password and its code, recovery only to a verified address and its link working once, logout ending one device, + a password change and "sign out everywhere" ending the others, an old hash made again at sign-in, the codes' TTL. - **Castopod (1.15.5, `peers/castopod.sh`):** the official image on the shared MySQL (database `castopod`, file cache, `CP_DISABLE_HTTPS`), its database and superadmin (admin@castopod.test) from `spark`, its REST API switched on in the `.env` the image rewrites at each start, `spark fediverse:broadcast` looping in the container. The podcast `@pod` is diff --git a/PrivaPub.ClientModels/User/AccountForms.cs b/PrivaPub.ClientModels/User/AccountForms.cs new file mode 100644 index 0000000..9beef18 --- /dev/null +++ b/PrivaPub.ClientModels/User/AccountForms.cs @@ -0,0 +1,53 @@ +using PrivaPub.ClientModels.Resources; + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace PrivaPub.ClientModels.User +{ + // The login's email address, set only with its password and only once a code sent to it comes back + // (/clientapi/user/email, /email/verify). + public class EmailChangeForm + { + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + EmailAddress(ErrorMessageResourceName = "InvalidEmail", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(254, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)), + Display(Name = "Email", ResourceType = typeof(FieldsNameResource))] + public string Email { get; set; } + + [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; } + } + + public class EmailCodeForm + { + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(16, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)), + Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))] + public string Code { get; set; } + } + + // what only the login's password may do (removing its email address) + public class PasswordForm + { + [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; } + } + + public class ViewAccount + { + public string UserName { get; set; } + public string Email { get; set; } + public bool IsEmailValidated { get; set; } = false; + public string PendingEmail { get; set; }//waiting for its code + public DateTime CreatedAt { get; set; } + } +} diff --git a/PrivaPub.ClientModels/User/ViewUserSettings.cs b/PrivaPub.ClientModels/User/ViewUserSettings.cs index 6e25e45..69c7de6 100644 --- a/PrivaPub.ClientModels/User/ViewUserSettings.cs +++ b/PrivaPub.ClientModels/User/ViewUserSettings.cs @@ -21,5 +21,8 @@ namespace PrivaPub.ClientModels.User public bool ThemeIsDarkMode { get; set; } = false; public bool ThemeIsLightGray { get; set; } = false; public bool ThemeIsDarkGray { get; set; } = false; + + // when a client last saved these; null until one did, so a client signing in keeps its own until then + public DateTime? SavedAt { get; set; } } } \ No newline at end of file diff --git a/PrivaPub/Controllers/ClientToServer/RootUserController.cs b/PrivaPub/Controllers/ClientToServer/RootUserController.cs index a643dc9..0d7e087 100644 --- a/PrivaPub/Controllers/ClientToServer/RootUserController.cs +++ b/PrivaPub/Controllers/ClientToServer/RootUserController.cs @@ -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 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 UpdateUser(UserForm userEmailForm) + [HttpPost, Route("/clientapi/user/logout/everywhere"), Authorize(Policy = Policies.IsUser)] + public async Task 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 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 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 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 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 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) { diff --git a/PrivaPub/Infrastructure/Backup/ServerBackup.cs b/PrivaPub/Infrastructure/Backup/ServerBackup.cs index 82b7b84..14b6898 100644 --- a/PrivaPub/Infrastructure/Backup/ServerBackup.cs +++ b/PrivaPub/Infrastructure/Backup/ServerBackup.cs @@ -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", diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 0531d1b..31685fc 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -185,6 +185,11 @@ namespace PrivaPub.Infrastructure.Data await Plain(token, e => e.Host, e => e.At); await DB.Default.Index().Key(s => s.Day, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token); await DB.Default.Index().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().Key(r => r.ExpiresAt, KeyType.Ascending).Option(o => o.ExpireAfter = TimeSpan.Zero).CreateAsync(token); + await Plain(token, r => r.CodeHash); + await DB.Default.Index().Key(v => v.ExpiresAt, KeyType.Ascending).Option(o => o.ExpireAfter = TimeSpan.Zero).CreateAsync(token); + await Plain(token, v => v.RootUserId); await DB.Default.Index().Key(d => d.Day, KeyType.Ascending).Key(d => d.Host, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token); await Plain(token, d => d.Host, d => d.Day); await DB.Default.Index().Key(d => d.Day, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token); diff --git a/PrivaPub/Infrastructure/Data/Migrations/_019_addresses_given_before_verification_are_kept.cs b/PrivaPub/Infrastructure/Data/Migrations/_019_addresses_given_before_verification_are_kept.cs new file mode 100644 index 0000000..9c1cd6d --- /dev/null +++ b/PrivaPub/Infrastructure/Data/Migrations/_019_addresses_given_before_verification_are_kept.cs @@ -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() + .Match(u => u.Email != null && u.Email != "" && u.DeletedAt == null && !u.IsEmailValidated) + .Modify(u => u.IsEmailValidated, true) + .ExecuteAsync(); + } + } +} diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 3061dc3..961d9c5 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -42,7 +42,8 @@ namespace PrivaPub.Middleware return service .Configure(configuration.GetSection(nameof(MongoSettings))) .Configure(configuration.GetSection(nameof(AppConfiguration))) - .Configure(configuration.GetSection("Registrations")); + .Configure(configuration.GetSection("Registrations")) + .Configure(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() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Jobs/Job.cs b/PrivaPub/Models/Jobs/Job.cs index 661122d..fd8b4fc 100644 --- a/PrivaPub/Models/Jobs/Job.cs +++ b/PrivaPub/Models/Jobs/Job.cs @@ -39,7 +39,8 @@ namespace PrivaPub.Models.Jobs ProcessMedia, ExportArchive, ImportArchive, - SyncFeeds + SyncFeeds, + SendEmailVerification } public enum JobState diff --git a/PrivaPub/Models/User/EmailVerification.cs b/PrivaPub/Models/User/EmailVerification.cs new file mode 100644 index 0000000..6818526 --- /dev/null +++ b/PrivaPub/Models/User/EmailVerification.cs @@ -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; } + } +} diff --git a/PrivaPub/Models/User/RootUser.cs b/PrivaPub/Models/User/RootUser.cs index b989add..2d7b3bc 100644 --- a/PrivaPub/Models/User/RootUser.cs +++ b/PrivaPub/Models/User/RootUser.cs @@ -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 EndedSessions { get; set; } = new();//sessions signed out one by one, until their tokens expire /// An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user. 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; diff --git a/PrivaPub/Services/AppConfigurationService.cs b/PrivaPub/Services/AppConfigurationService.cs index 31bad41..b506bae 100644 --- a/PrivaPub/Services/AppConfigurationService.cs +++ b/PrivaPub/Services/AppConfigurationService.cs @@ -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) - { - _dbEntities = dbEntities; - _appConfigurationFromFile = appConfiguration.CurrentValue; - } + readonly IOptionsMonitor _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; - dbAppConfiguration = _appConfigurationFromFile; - await DB.Default.SaveAsync(dbAppConfiguration); - AppConfiguration = dbAppConfiguration; - } + public AppConfiguration AppConfiguration => _appConfiguration.CurrentValue; + + public Task Init() => Task.CompletedTask; } } diff --git a/PrivaPub/Services/EmailVerificationJob.cs b/PrivaPub/Services/EmailVerificationJob.cs new file mode 100644 index 0000000..2256634 --- /dev/null +++ b/PrivaPub/Services/EmailVerificationJob.cs @@ -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 _localizer; + readonly ILogger _logger; + + public EmailVerificationJob(IMailer mailer, IStringLocalizer localizer, ILogger 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 Handle(Job job, CancellationToken token) + { + var payload = JsonSerializer.Deserialize(job.Payload); + var now = DateTime.UtcNow; + var pending = string.IsNullOrEmpty(payload?.VerificationId) ? default + : await DB.Default.Find().Match(v => v.ID == payload.VerificationId && v.ExpiresAt > now).ExecuteFirstAsync(token); + if (pending == default) + return JobOutcome.Done; + var user = await DB.Default.Find().MatchID(pending.RootUserId).ExecuteFirstAsync(token); + if (user is not { DeletedAt: null, IsBanned: false }) + return JobOutcome.Done; + if (await DB.Default.Find().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().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"); + } + } + } +} diff --git a/PrivaPub/Services/IRootUsersService.cs b/PrivaPub/Services/IRootUsersService.cs index 28482bb..3bc3e59 100644 --- a/PrivaPub/Services/IRootUsersService.cs +++ b/PrivaPub/Services/IRootUsersService.cs @@ -18,8 +18,6 @@ namespace PrivaPub.Services Task RemoveUserAsync(UsersIds usersIds); Task RemoveSelfAsync(string rootId, string password); - Task UpdateUserAsync(UserForm userEmailForm, string userId); - Task UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId); Task GetUserSettingsAsync(string userId, LoginForm loginForm = default); @@ -31,5 +29,13 @@ namespace PrivaPub.Services Task IsValidRecoveryCode(string recoveryCode); Task ChangePassword(NewPasswordForm newPasswordForm); + + Task AccountAsync(string rootId); + + Task RequestEmailChangeAsync(string rootId, EmailChangeForm form); + + Task VerifyEmailAsync(string rootId, string code); + + Task RemoveEmailAsync(string rootId, string password); } } \ No newline at end of file diff --git a/PrivaPub/Services/Mailer.cs b/PrivaPub/Services/Mailer.cs new file mode 100644 index 0000000..2d34a40 --- /dev/null +++ b/PrivaPub/Services/Mailer.cs @@ -0,0 +1,41 @@ +using MailKit.Net.Smtp; + +using Microsoft.Extensions.Options; + +using MimeKit; + +using PrivaPub.Models; + +namespace PrivaPub.Services +{ + public interface IMailer + { + /// Sends a plain-text email; throws when SMTP fails, so the job that sends it retries. + 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 _app; + + public Mailer(IOptionsMonitor 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); + } + } +} diff --git a/PrivaPub/Services/RecoveryJob.cs b/PrivaPub/Services/RecoveryJob.cs index c876cc7..5e39b23 100644 --- a/PrivaPub/Services/RecoveryJob.cs +++ b/PrivaPub/Services/RecoveryJob.cs @@ -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 _localizer; readonly ILogger _logger; - public RecoveryJob(AppConfigurationService app, IStringLocalizer localizer, ILogger logger) + public RecoveryJob(IMailer mailer, IStringLocalizer localizer, ILogger 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().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(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) diff --git a/PrivaPub/Services/RootJwt.cs b/PrivaPub/Services/RootJwt.cs index ced0e48..52953b9 100644 --- a/PrivaPub/Services/RootJwt.cs +++ b/PrivaPub/Services/RootJwt.cs @@ -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; } diff --git a/PrivaPub/Services/RootSessions.cs b/PrivaPub/Services/RootSessions.cs index c40ccac..9fa3068 100644 --- a/PrivaPub/Services/RootSessions.cs +++ b/PrivaPub/Services/RootSessions.cs @@ -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().MatchID(rootId) .Modify(u => u.CredentialsChangedAt, DateTime.UtcNow) .Modify(u => u.SessionStamp, Guid.NewGuid().ToString("N")) + .Modify(u => u.EndedSessions, new List())//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().MatchID(rootId) + .Modify(b => b.PullFilter(u => u.EndedSessions, s => s.Until < now)) + .ExecuteAsync(token); + await DB.Default.Update().MatchID(rootId) + .Modify(b => b.Push(u => u.EndedSessions, new EndedSession { Sid = sessionId, Until = now + _authTokens.Lifetime })) + .ExecuteAsync(token); + } } } diff --git a/PrivaPub/Services/RootUsersService.cs b/PrivaPub/Services/RootUsersService.cs index f9b55d9..1aded5f 100644 --- a/PrivaPub/Services/RootUsersService.cs +++ b/PrivaPub/Services/RootUsersService.cs @@ -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() + .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 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() - .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 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().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 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().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 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(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 VerifyEmailAsync(string rootId, string code) + { + var result = new WebResult(); + try + { + var now = DateTime.UtcNow; + var pending = await DB.Default.Find().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(pending.ID); + else + await DB.Default.Update().MatchID(pending.ID).Modify(b => b.Inc(v => v.Attempts, 1)).ExecuteAsync(); + return result.Invalidate(Localizer[EmailCodeInvalid]); + } + await DB.Default.DeleteAsync(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().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 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().MatchID(rootId) + .Modify(u => u.Email, null) + .Modify(u => u.IsEmailValidated, false) + .Modify(u => u.UpdatedAt, DateTime.UtcNow) + .ExecuteAsync(); + await DB.Default.DeleteAsync(v => v.RootUserId == rootId); + await DB.Default.DeleteAsync(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); + } + } } } diff --git a/PrivaPub/StaticServices/AuthTokenManager.cs b/PrivaPub/StaticServices/AuthTokenManager.cs index dd185d4..6a289b9 100644 --- a/PrivaPub/StaticServices/AuthTokenManager.cs +++ b/PrivaPub/StaticServices/AuthTokenManager.cs @@ -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"])); + + /// A token for the root; a renewal passes the session it renews, a new sign-in gets a new one. + 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; } } -} \ No newline at end of file +} diff --git a/PrivaPub/appsettings.Development.json b/PrivaPub/appsettings.Development.json index 1b6da45..d767092 100644 --- a/PrivaPub/appsettings.Development.json +++ b/PrivaPub/appsettings.Development.json @@ -42,7 +42,7 @@ "SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ" }, "HashingOptions": { - "Iterations": 10101 + "Iterations": 210000 }, "Jwt": { "Key": "bbLOwQyh0jYtJ8Dn6WKCneydLJkqeElqUM03Qwtr7pEMyq/qCZD6v/Sh5Kwrq6YpvJm8GSTHfBiCajUmicfgKw==", diff --git a/PrivaPub/appsettings.Production.json b/PrivaPub/appsettings.Production.json index d95b313..09f8c28 100644 --- a/PrivaPub/appsettings.Production.json +++ b/PrivaPub/appsettings.Production.json @@ -46,7 +46,7 @@ "SmtpPassword": "c4kXUJFQeKC2dVQbZqxZ" }, "HashingOptions": { - "Iterations": 10101 + "Iterations": 210000 }, "Jwt": { "Key": "FVkEHw8t84HVXE/+3fyY6gH1AJIlLVNE9UqOzFNBdRKsdFuKrCFBvys0IQfZu+d2Qgg6KtslgntiedPMO98iKg==", diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 8e8b77b..55dba70 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -43,6 +43,11 @@ decepub.test { reverse_proxy pasture-decepub:80 } +mailpit.test { + tls internal + reverse_proxy pasture-mailpit:8025 +} + hollo.test { tls internal reverse_proxy pasture-hollo:3000 diff --git a/tools/pasture/appsettings.Pasture.json b/tools/pasture/appsettings.Pasture.json index e848047..b930eea 100644 --- a/tools/pasture/appsettings.Pasture.json +++ b/tools/pasture/appsettings.Pasture.json @@ -6,8 +6,9 @@ "AppConfiguration": { "SupportedLanguages": [ "en" ], "BackendBaseAddress": "https://privapub.test", - "FrontendBaseAddress": "https://privapub.test", - "HashingOptions": { "Iterations": 10101 }, + "FrontendBaseAddress": "https://decepub.test", + "EmailConfiguration": { "SmtpServer": "pasture-mailpit", "SmtpPort": 1025, "UseSSL": false, "SmtpUsername": "privapub@privapub.test", "SmtpPassword": "" }, + "HashingOptions": { "Iterations": 210000 }, "Jwt": { "Key": "pasture-only-key-not-a-secret-pasture-only-key-not-a-secret-0123456789", "Issuer": "http://privapub.test", diff --git a/tools/pasture/peers/mailpit.sh b/tools/pasture/peers/mailpit.sh new file mode 100644 index 0000000..5fee8a3 --- /dev/null +++ b/tools/pasture/peers/mailpit.sh @@ -0,0 +1,15 @@ +# Mailpit 1.31.4 as mailpit.test: the pasture's mail server. PrivaPub sends to it on pasture-mailpit:1025 (plain SMTP, any +# login accepted, appsettings.Pasture.json's EmailConfiguration), and the scenarios read what arrived through its API +# (https://mailpit.test:6443/api/v1/...). Nothing leaves the pasture. +MAILPIT_IMAGE=${MAILPIT_IMAGE:-docker.io/axllent/mailpit:v1.31.4} + +mailpit_up() { + podman run -d --replace --name pasture-mailpit --label pasture=1 --network $net \ + -e MP_SMTP_AUTH_ACCEPT_ANY=1 -e MP_SMTP_AUTH_ALLOW_INSECURE=1 -e MP_MAX_MESSAGES=5000 \ + $MAILPIT_IMAGE >/dev/null + for _ in $(seq 1 60); do + site mailpit.test -s -o /dev/null -w '%{http_code}' https://mailpit.test:6443/api/v1/info 2>/dev/null | grep -q 200 && break + sleep 1 + done + echo "mailpit: https://mailpit.test:6443" +} diff --git a/tools/pasture/scenarios/accounts.sh b/tools/pasture/scenarios/accounts.sh new file mode 100644 index 0000000..365bc51 --- /dev/null +++ b/tools/pasture/scenarios/accounts.sh @@ -0,0 +1,98 @@ +# Accounts (completion program R2): an email address is set only with the password and a code sent to it; recovery writes +# only to a verified address, and its link works once; a password change and "sign out everywhere" end every session, +# while a logout ends only its own device; a hash made at fewer iterations is made again at sign-in; recovery codes expire +# through a TTL index. Needs the mailpit peer. +MP=https://mailpit.test:6443 +mp() { site mailpit.test -s "$@"; } +# mp_text
: the newest message sent to the address, its text, or nothing +mp_text() { + local id + id=$(mp "$MP/api/v1/search?query=to:$1&limit=1" | j "print(d['messages'][0]['ID'] if d and d.get('messages') else '')") + [ -n "$id" ] && mp "$MP/api/v1/message/$id" | j "print(d['Text'])" +} +mp_count() { mp "$MP/api/v1/search?query=to:$1" | j "print(d['messages_count'] if d else 0)"; } +api() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -X "$method" -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' "$P$path" "$@"; } +code_of() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -o /dev/null -w '%{http_code}' -X "$method" -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' "$P$path" "$@"; } +login() { curl -s -X POST "$P/clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\"$1\",\"password\":\"$2\"}" | j "print(d['token'])"; } + +echo "accounts" +run=$(date +%s) +user="acct$run"; pw='Accounts-Pass-1!'; pw2='Accounts-Pass-2!'; mail="$user@example.test" +curl -s -o /dev/null -X POST "$P/clientapi/user/signup" -H 'Content-Type: application/json' -d "{\"userName\":\"$user\",\"password\":\"$pw\"}" +A=$(login "$user" "$pw") +[ -n "$A" ] && ok "a login for the scenario" || { ko "no login for the scenario"; return 1; } + +echo " an email address, with the password and a code" +[ "$(code_of "$A" POST /clientapi/user/update -d "{\"email\":\"$mail\"}")" = 422 ] \ + && ok "the old route changes no address" || ko "the old route still changes the address" +[ "$(code_of "$A" POST /clientapi/user/email -d "{\"email\":\"$mail\",\"password\":\"wrong-Pass-9\"}")" = 403 ] \ + && ok "a wrong password asks for no address" || ko "a wrong password was taken" +sentence=$(api "$A" POST /clientapi/user/email -d "{\"email\":\"$mail\",\"password\":\"$pw\"}") +[ "$(api "$A" GET /clientapi/user/account | j "print(d['pendingEmail'])")" = "$mail" ] \ + && ok "the address waits for its code" || ko "the address is not waiting for its code" +until_true 20 '[ -n "$(mp_text "$mail")" ]' && ok "the code was emailed" || ko "no code arrived" +code=$(mp_text "$mail" | grep -oE '\b[0-9]{6}\b' | head -1) +[ "$(code_of "$A" POST /clientapi/user/email/verify -d '{"code":"000000"}')" = 400 ] \ + && ok "a wrong code is refused" || ko "a wrong code was taken" +[ "$(api "$A" POST /clientapi/user/email/verify -d "{\"code\":\"$code\"}" | j "print(d['isEmailValidated'] and d['email'])")" = "$mail" ] \ + && ok "the code verifies the address" || ko "the code did not verify the address" +other="acct${run}b" +curl -s -o /dev/null -X POST "$P/clientapi/user/signup" -H 'Content-Type: application/json' -d "{\"userName\":\"$other\",\"password\":\"$pw\"}" +O=$(login "$other" "$pw") +[ "$(api "$O" POST /clientapi/user/email -d "{\"email\":\"$mail\",\"password\":\"$pw\"}")" = "$sentence" ] \ + && ok "an address in use gets the same answer" || ko "an address in use is told apart" +sleep 6 +[ "$(mp_count "$mail")" = 1 ] && ok "no code goes to an address another login verified" || ko "a code went to an address in use" + +echo " recovery" +curl -s -o /dev/null -X POST "$P/clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"email\":\"$mail\"}" +until_true 20 '[ "$(mp_count "$mail")" = 2 ]' && ok "the recovery link was emailed" || ko "no recovery link arrived" +rc=$(mp_text "$mail" | grep -oE 'rc=[0-9a-f]{64}' | head -1 | cut -d= -f2) +grep -q 'https://decepub.test/password-recovery?rc=' <<<"$(mp_text "$mail")" && ok "the link opens decePub" || ko "the link does not open decePub" +[ "$(curl -s -X POST "$P/clientapi/user/recover/valid" -H 'Content-Type: application/json' -d "\"$rc\"")" = true ] \ + && ok "the recovery code is valid" || ko "the recovery code is not valid" +B=$(login "$user" "$pw") +curl -s -o /dev/null -X POST "$P/clientapi/user/recover/update/password" -H 'Content-Type: application/json' \ + -d "{\"newPassword\":\"$pw2\",\"repeatedPassword\":\"$pw2\",\"recoveryCode\":\"$rc\"}" +[ "$(curl -s -X POST "$P/clientapi/user/recover/valid" -H 'Content-Type: application/json' -d "\"$rc\"")" = false ] \ + && ok "the recovery code works once" || ko "the recovery code works twice" +[ "$(code_of "$B" GET /clientapi/user/account)" = 401 ] && ok "recovery ended every session" || ko "a session outlived the recovery" +[ -n "$(login "$user" "$pw2")" ] && ok "the new password signs in" || ko "the new password does not sign in" +unverified="acct${run}c@example.test" +api "$O" POST /clientapi/user/email -d "{\"email\":\"$unverified\",\"password\":\"$pw\"}" >/dev/null +until_true 20 '[ "$(mp_count "$unverified")" = 1 ]' +curl -s -o /dev/null -X POST "$P/clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"email\":\"$unverified\"}" +curl -s -o /dev/null -X POST "$P/clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"userName\":\"$other\"}" +sleep 6 +[ "$(mp_count "$unverified")" = 1 ] && ok "no recovery goes to an unverified address" || ko "a recovery went to an unverified address" + +echo " sessions" +C=$(login "$user" "$pw2"); D=$(login "$user" "$pw2") +[ "$(code_of "$C" GET /clientapi/user/logout)" = 200 ] && ok "a device signs out" || ko "signing out failed" +[ "$(code_of "$C" GET /clientapi/user/account)" = 401 ] && ok "its token is refused" || ko "its token still works" +[ "$(code_of "$D" GET /clientapi/user/account)" = 200 ] && ok "another device stays signed in" || ko "another device was signed out" +renewed=$(api "$D" GET /clientapi/user/sniff/again | j "print(d['token'])") +api "$D" GET /clientapi/user/logout >/dev/null +[ "$(code_of "$renewed" GET /clientapi/user/account)" = 401 ] && ok "a renewed token belongs to the session it renewed" || ko "a renewed token outlived its session's logout" +E=$(login "$user" "$pw2"); F=$(login "$user" "$pw2") +changed=$(api "$E" POST /clientapi/user/update/password \ + -d "{\"oldPassword\":\"$pw2\",\"newPassword\":\"$pw\",\"repeatedPassword\":\"$pw\"}" | j "print(d['token'])") +[ "$(code_of "$F" GET /clientapi/user/account)" = 401 ] && ok "a password change ends the other sessions" || ko "another session outlived a password change" +[ "$(code_of "$changed" GET /clientapi/user/account)" = 200 ] && ok "the device that changed it goes on" || ko "the device that changed it was signed out" +G=$(login "$user" "$pw") +[ "$(code_of "$G" POST /clientapi/user/logout/everywhere)" = 200 ] && ok "signing out everywhere" || ko "signing out everywhere failed" +[ "$(code_of "$changed" GET /clientapi/user/account)" = 401 ] && [ "$(code_of "$G" GET /clientapi/user/account)" = 401 ] \ + && ok "every session ended" || ko "a session outlived signing out everywhere" + +echo " hashes" +old_hash=$(python3 -c " +import base64,hashlib,os +salt=os.urandom(16) +key=hashlib.pbkdf2_hmac('sha512', b'$pw', salt, 10101, 32) +print(base64.b64encode(key).decode()+'~'+base64.b64encode(salt).decode()+'~10101')") +podman exec pasture-mongo mongosh --quiet PrivaPub --eval "db.RootUser.updateOne({UserName: '$user'}, {\$set: {HashedPassword: '$old_hash'}})" >/dev/null +[ -n "$(login "$user" "$pw")" ] && ok "an old hash still signs in" || ko "an old hash no longer signs in" +iterations=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.RootUser.findOne({UserName: '$user'}).HashedPassword.split('~')[2])") +[ "$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" diff --git a/tools/pasture/sweep.sh b/tools/pasture/sweep.sh index 8aae459..784a911 100755 --- a/tools/pasture/sweep.sh +++ b/tools/pasture/sweep.sh @@ -6,8 +6,8 @@ # PrivaPub runs with SecureMode on, as in production, unless PRIVAPUB_ENV says otherwise; DOTNET and PASTURE_PORT reach # run.sh as they are. Each batch's output is kept in out/sweep-.log. # usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order) -# A gts mastodon misskey sharkey akkoma, with relay and aoderelay; scenarios: those five, threads followsync pins -# moves relay (relay's covers both relays) +# A gts mastodon misskey sharkey akkoma, with relay, aoderelay and mailpit; scenarios: those five, threads followsync +# pins moves relay (relay's covers both relays) accounts (mailpit's) # B lemmy lemmy19 piefed mbin nodebb # C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale # D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla @@ -19,7 +19,7 @@ export PRIVAPUB_ENV=${PRIVAPUB_ENV:-Federation__SecureMode=true} batch_peers() { case "$1" in - A) echo "gts mastodon misskey sharkey akkoma relay aoderelay" ;; + A) echo "gts mastodon misskey sharkey akkoma relay aoderelay mailpit" ;; B) echo "lemmy lemmy19 piefed mbin nodebb" ;; C) echo "peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale" ;; D) echo "friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla" ;; @@ -27,7 +27,7 @@ batch_peers() { } batch_scenarios() { case "$1" in - A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay" ;; + A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts" ;; *) batch_peers "$1" ;; esac }