From 0817d7860b8d870cb72090ecffe8659c4f4bb862 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 20 May 2026 17:45:18 +0200 Subject: [PATCH 01/15] add email change flow and harden password resets --- API.IntegrationTests/Tests/MailTests.cs | 223 +++ .../Account/Authenticated/ChangeEmail.cs | 36 +- API/Controller/Account/VerifyEmail.cs | 12 +- API/Models/Requests/ChangeEmailRequest.cs | 7 +- API/Services/Account/AccountService.cs | 79 +- API/Services/Account/IAccountService.cs | 17 +- API/Services/Email/Smtp/SmtpEmailService.cs | 2 +- Common/Constants/Constants.cs | 2 + Common/Errors/AccountError.cs | 12 + ...40_AddPasswordAndEmailVersions.Designer.cs | 1487 +++++++++++++++++ ...60520153340_AddPasswordAndEmailVersions.cs | 62 + .../OpenShockContextModelSnapshot.cs | 26 +- Common/OpenShockDb/OpenShockContext.cs | 12 + Common/OpenShockDb/User.cs | 14 + Common/OpenShockDb/UserEmailChange.cs | 39 + Common/OpenShockDb/UserPasswordReset.cs | 29 + Cron/Jobs/ClearOldEmailChangesJob.cs | 36 + 17 files changed, 2064 insertions(+), 31 deletions(-) create mode 100644 Common/Migrations/20260520153340_AddPasswordAndEmailVersions.Designer.cs create mode 100644 Common/Migrations/20260520153340_AddPasswordAndEmailVersions.cs create mode 100644 Cron/Jobs/ClearOldEmailChangesJob.cs diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 544f2c99..e4b063fa 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -172,6 +172,229 @@ public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword() await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); } + // --- Change Email --- + + [Test] + public async Task ChangeEmailFlow_ViaEmailLink_ChangesEmail() + { + const string oldEmail = "mail-chgemail-flow@test.org"; + const string newEmail = "mail-chgemail-flow-new@test.org"; + const string password = "SecurePassword123#"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "mailchgemailflow", oldEmail, password); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Initiate the email change + var initiateResponse = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new + { + currentPassword = password, + email = newEmail + })); + await Assert.That(initiateResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // The verification email goes to the NEW address + var message = await mailpit.WaitForMessageAsync(newEmail); + await Assert.That(message).IsNotNull(); + + var fullMessage = await mailpit.GetMessageAsync(message!.Id); + await Assert.That(fullMessage).IsNotNull(); + + var token = ExtractQueryParam(fullMessage!.Html, "token"); + await Assert.That(token).IsNotNull().And.IsNotEmpty(); + + // Email is not changed yet + await using (var scope = WebApplicationFactory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var beforeUser = await db.Users.AsNoTracking().FirstAsync(u => u.Id == user.Id); + await Assert.That(beforeUser.Email).IsEqualTo(oldEmail); + } + + // Use the token to complete the change + using var anonClient = WebApplicationFactory.CreateClient(); + var verifyResponse = await anonClient.PostAsync($"/1/account/verify-email?token={token}", null); + await Assert.That(verifyResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Email is now updated + await using (var scope = WebApplicationFactory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var afterUser = await db.Users.AsNoTracking().FirstAsync(u => u.Id == user.Id); + await Assert.That(afterUser.Email).IsEqualTo(newEmail); + } + + // Re-using the same token must now fail + var replayResponse = await anonClient.PostAsync($"/1/account/verify-email?token={token}", null); + await Assert.That(replayResponse.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task ChangeEmail_WrongPassword_Returns403_AndSendsNoEmail() + { + const string oldEmail = "mail-chgemail-badpwd@test.org"; + const string newEmail = "mail-chgemail-badpwd-new@test.org"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "mailchgemailbadpwd", oldEmail, "CorrectPassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new + { + currentPassword = "WrongPassword!", + email = newEmail + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + + // No verification email should have been dispatched + var message = await mailpit.WaitForMessageAsync(newEmail, TimeSpan.FromSeconds(2)); + await Assert.That(message).IsNull(); + } + + [Test] + public async Task ChangeEmailFlow_SecondPendingRequest_InvalidatedAfterFirstCompletes() + { + const string oldEmail = "mail-chgemail-sibling@test.org"; + const string firstNewEmail = "mail-chgemail-sibling-first@test.org"; + const string secondNewEmail = "mail-chgemail-sibling-second@test.org"; + const string password = "SecurePassword123#"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "mailchgemailsibling", oldEmail, password); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Initiate two concurrent email change requests + var firstInit = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new + { + currentPassword = password, + email = firstNewEmail + })); + await Assert.That(firstInit.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var secondInit = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new + { + currentPassword = password, + email = secondNewEmail + })); + await Assert.That(secondInit.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var firstMessage = await mailpit.WaitForMessageAsync(firstNewEmail); + var secondMessage = await mailpit.WaitForMessageAsync(secondNewEmail); + await Assert.That(firstMessage).IsNotNull(); + await Assert.That(secondMessage).IsNotNull(); + + var firstFull = await mailpit.GetMessageAsync(firstMessage!.Id); + var secondFull = await mailpit.GetMessageAsync(secondMessage!.Id); + var firstToken = ExtractQueryParam(firstFull!.Html, "token"); + var secondToken = ExtractQueryParam(secondFull!.Html, "token"); + await Assert.That(firstToken).IsNotNull().And.IsNotEmpty(); + await Assert.That(secondToken).IsNotNull().And.IsNotEmpty(); + + using var anonClient = WebApplicationFactory.CreateClient(); + + // Complete the first request — email becomes firstNewEmail + var firstVerify = await anonClient.PostAsync($"/1/account/verify-email?token={firstToken}", null); + await Assert.That(firstVerify.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Second pending request must now be unusable because its OldEmail no longer matches the user's current email + var secondVerify = await anonClient.PostAsync($"/1/account/verify-email?token={secondToken}", null); + await Assert.That(secondVerify.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var afterUser = await db.Users.AsNoTracking().FirstAsync(u => u.Id == user.Id); + await Assert.That(afterUser.Email).IsEqualTo(firstNewEmail); + } + + [Test] + public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompletes() + { + const string email = "mail-pwreset-sibling@test.org"; + const string firstNewPassword = "FirstNewPassword123#"; + const string secondNewPassword = "SecondNewPassword456#"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + await TestHelper.CreateUserInDb(WebApplicationFactory, "mailpwresetsibling", email, "OldPassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + + // Initiate two password reset requests + var firstInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + await Assert.That(firstInit.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Wait for first message before triggering second so we can distinguish them + var firstMessage = await mailpit.WaitForMessageAsync(email); + await Assert.That(firstMessage).IsNotNull(); + + var secondInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + await Assert.That(secondInit.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Find the second (newer) message + MailpitHelper.MailpitMessage? secondMessage = null; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); + while (DateTime.UtcNow < deadline && secondMessage is null) + { + var all = await mailpit.GetAllMessagesAsync(); + secondMessage = all.FirstOrDefault(m => + m.Id != firstMessage!.Id && + m.To?.Any(t => t.Address.Equals(email, StringComparison.OrdinalIgnoreCase)) == true); + if (secondMessage is null) await Task.Delay(300); + } + await Assert.That(secondMessage).IsNotNull(); + + var firstFull = await mailpit.GetMessageAsync(firstMessage!.Id); + var secondFull = await mailpit.GetMessageAsync(secondMessage!.Id); + var (firstResetId, firstSecret) = ExtractPasswordResetParams(firstFull!.Html); + var (secondResetId, secondSecret) = ExtractPasswordResetParams(secondFull!.Html); + await Assert.That(firstResetId).IsNotNull().And.IsNotEmpty(); + await Assert.That(secondResetId).IsNotNull().And.IsNotEmpty(); + + // Complete the first reset + var firstComplete = await client.PostAsync( + $"/1/account/recover/{firstResetId}/{firstSecret}", + TestHelper.JsonContent(new { password = firstNewPassword })); + await Assert.That(firstComplete.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // The second (sibling) reset must no longer be usable + var secondCheck = await client.SendAsync(new HttpRequestMessage( + HttpMethod.Head, $"/1/account/recover/{secondResetId}/{secondSecret}")); + await Assert.That(secondCheck.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + + var secondComplete = await client.PostAsync( + $"/1/account/recover/{secondResetId}/{secondSecret}", + TestHelper.JsonContent(new { password = secondNewPassword })); + await Assert.That(secondComplete.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + + // First (new) password still works + var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new + { + email, + password = firstNewPassword + })); + await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task ChangeEmail_AlreadyInUse_Returns409() + { + const string takenEmail = "mail-chgemail-taken-existing@test.org"; + const string ownEmail = "mail-chgemail-taken-own@test.org"; + const string password = "SecurePassword123#"; + + await TestHelper.CreateUserInDb(WebApplicationFactory, "mailchgemailtaken1", takenEmail, password); + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "mailchgemailtaken2", ownEmail, password); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new + { + currentPassword = password, + email = takenEmail + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + // --- Helpers --- /// diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index de552993..6e1a7a6f 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -1,23 +1,41 @@ -using System.Net.Mime; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; -using OpenShock.Common.Models; +using OpenShock.Common.Errors; +using OpenShock.Common.Problems; +using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account.Authenticated; public sealed partial class AuthenticatedAccountController { /// - /// Change the password of the current user + /// Initiate an email change for the current user. A verification link is sent to the new + /// address; the change is not applied until that link is opened. /// /// - /// - /// [HttpPost("email")] [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - public Task ChangeEmail([FromBody] ChangeEmailRequest body) + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // EmailChangeUnchanged + [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword + [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse + [ProducesResponseType(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany + public async Task ChangeEmail([FromBody] ChangeEmailRequest body) { - throw new NotImplementedException(); + if (string.IsNullOrEmpty(CurrentUser.PasswordHash) || !HashingUtils.VerifyPassword(body.CurrentPassword, CurrentUser.PasswordHash).Verified) + { + return Problem(AccountError.PasswordChangeInvalidPassword); + } + + var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email); + + return result.Match( + success => Ok(), + alreadyInUse => Problem(AccountError.EmailChangeAlreadyInUse), + unchanged => Problem(AccountError.EmailChangeUnchanged), + tooMany => Problem(AccountError.EmailChangeTooMany), + deactivated => Problem(AccountError.AccountDeactivated), + notFound => throw new Exception("Unexpected result, apparently our current user does not exist...")); } -} \ No newline at end of file +} diff --git a/API/Controller/Account/VerifyEmail.cs b/API/Controller/Account/VerifyEmail.cs index f107bf40..091d1f54 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -1,6 +1,7 @@ using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; +using OpenShock.Common.Errors; using OpenShock.Common.Problems; namespace OpenShock.API.Controller.Account; @@ -8,17 +9,18 @@ namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { /// - /// Verify account email + /// Verify a pending email change using the token from the verification email. /// - /// + /// Email change verified and applied + /// Token is invalid or the request has expired [HttpPost("verify-email")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("1")] public async Task EmailVerify([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) { bool ok = await _accountService.TryVerifyEmailAsync(token, cancellationToken); - - return Ok(); + + return ok ? Ok() : Problem(AccountError.EmailChangeNotFound); } } \ No newline at end of file diff --git a/API/Models/Requests/ChangeEmailRequest.cs b/API/Models/Requests/ChangeEmailRequest.cs index 70177ce6..8b0992fd 100644 --- a/API/Models/Requests/ChangeEmailRequest.cs +++ b/API/Models/Requests/ChangeEmailRequest.cs @@ -1,10 +1,13 @@ - +using System.ComponentModel.DataAnnotations; using OpenShock.Common.DataAnnotations; namespace OpenShock.API.Models.Requests; public sealed class ChangeEmailRequest { - [EmailAddress(true)] + [Required(AllowEmptyStrings = false)] + public required string CurrentPassword { get; set; } + + [OpenShock.Common.DataAnnotations.EmailAddress(true)] public required string Email { get; set; } } \ No newline at end of file diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 08c766bf..60b6c6d4 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -381,14 +381,15 @@ public async Task> CheckPasswordResetExi { var validSince = DateTime.UtcNow - Duration.PasswordResetRequestLifetime; var reset = await _db.UserPasswordResets.FirstOrDefaultAsync(x => - x.Id == passwordResetId && x.UsedAt == null && x.CreatedAt >= validSince, + x.Id == passwordResetId && x.UsedAt == null && x.CreatedAt >= validSince + && x.PasswordVersionAtCreate == x.User.PasswordVersion, cancellationToken: cancellationToken); if (reset is null) return new NotFound(); var result = HashingUtils.VerifyToken(secret, reset.TokenHash); if (!result.Verified) return new SecretInvalid(); - + return new Success(); } @@ -416,7 +417,8 @@ public async Task x.User) .Include(x => x.User.UserDeactivation) - .FirstOrDefaultAsync(x => x.Id == passwordResetId && x.UsedAt == null && x.CreatedAt >= validSince); + .FirstOrDefaultAsync(x => x.Id == passwordResetId && x.UsedAt == null && x.CreatedAt >= validSince + && x.PasswordVersionAtCreate == x.User.PasswordVersion); if (reset is null) return new NotFound(); if (reset.User.ActivatedAt is null) return new AccountNotActivated(); if (reset.User.UserDeactivation is not null) return new AccountDeactivated(); @@ -446,6 +449,7 @@ public async Task> ChangePasswordAs if (user.UserDeactivation is not null) return new AccountDeactivated(); user.PasswordHash = HashingUtils.HashPassword(newPassword); + user.PasswordVersion++; // Any outstanding reset row for this user has a stale PasswordVersionAtCreate after this — predicate handles invalidation. await _db.SaveChangesAsync(); return new Success(); } + /// + public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail) + { + var lowerCaseEmail = newEmail.ToLowerInvariant(); + var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; + + var data = await _db.Users + .Where(x => x.Id == userId) + .Include(x => x.UserDeactivation) + .Select(x => new + { + User = x, + PendingCount = x.EmailChanges.Count(y => y.UsedAt == null && y.CreatedAt >= validSince) + }) + .FirstOrDefaultAsync(); + if (data is null) return new NotFound(); + if (data.User.UserDeactivation is not null) return new AccountDeactivated(); + if (string.Equals(data.User.Email, lowerCaseEmail, StringComparison.Ordinal)) return new EmailUnchanged(); + if (data.PendingCount >= 3) return new TooManyEmailChanges(); + + if (await IsEmailProviderBlacklisted(lowerCaseEmail)) + return new EmailAlreadyInUse(); // Don't reveal blacklist hits + + if (await _db.Users.AnyAsync(x => x.Email == lowerCaseEmail)) + return new EmailAlreadyInUse(); + + var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength); + var emailChange = new UserEmailChange + { + Id = Guid.CreateVersion7(), + UserId = data.User.Id, + OldEmail = data.User.Email, + NewEmail = lowerCaseEmail, + TokenHash = HashingUtils.HashToken(token), + EmailVersionAtCreate = data.User.EmailVersion + }; + _db.UserEmailChanges.Add(emailChange); + await _db.SaveChangesAsync(); + + await _emailService.VerifyEmail(new Contact(lowerCaseEmail, data.User.Name), + new Uri(_frontendConfig.BaseUrl, $"/verify-email?token={token}")); + + return new Success(); + } + public async Task TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default) { var hash = HashingUtils.HashToken(token); + var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; + + var change = await _db.UserEmailChanges + .Include(x => x.User).ThenInclude(u => u.UserDeactivation) + .FirstOrDefaultAsync(x => x.TokenHash == hash && x.UsedAt == null && x.CreatedAt >= validSince + && x.EmailVersionAtCreate == x.User.EmailVersion + && x.User.UserDeactivation == null && x.User.ActivatedAt != null, cancellationToken); - int nChanges = await _db.UserEmailChanges - .Where(x => x.TokenHash == hash && x.UsedAt == null && x.User.Email == x.OldEmail && x.User.UserDeactivation == null && x.User.ActivatedAt != null) - .ExecuteUpdateAsync(spc => spc - .SetProperty(x => x.UsedAt, _ => DateTime.UtcNow) - .SetProperty(x => x.User.Email, x => x.NewEmail) - , cancellationToken); + if (change is null) return false; - return nChanges > 0; + change.UsedAt = DateTime.UtcNow; + change.User.Email = change.NewEmail; + change.User.EmailVersion++; // Predicate-bound: every other pending change for this user is now invalid. + + await _db.SaveChangesAsync(cancellationToken); + return true; } private async Task CheckPassword(string password, User user) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 41b9d445..df530a81 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -117,7 +117,16 @@ public interface IAccountService public Task> ChangePasswordAsync(Guid userId, string newPassword); /// - /// + /// Creates a new email change request and sends a verification email to the new address. + /// The email change is not applied until the user confirms via . + /// + /// Id of the user whose email is being changed. + /// Requested new email address. + /// + public Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail); + + /// + /// Verifies a pending email change using the supplied token. On success the user's email is updated. /// /// /// @@ -138,4 +147,8 @@ public interface IAccountService public readonly struct UsernameTaken; -public readonly struct RecentlyChanged; \ No newline at end of file +public readonly struct RecentlyChanged; + +public readonly struct EmailAlreadyInUse; +public readonly struct EmailUnchanged; +public readonly struct TooManyEmailChanges; \ No newline at end of file diff --git a/API/Services/Email/Smtp/SmtpEmailService.cs b/API/Services/Email/Smtp/SmtpEmailService.cs index d93e9a3d..c2d44f3e 100644 --- a/API/Services/Email/Smtp/SmtpEmailService.cs +++ b/API/Services/Email/Smtp/SmtpEmailService.cs @@ -51,7 +51,7 @@ public Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellat /// public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default) - => SendMail(to, _templates.EmailVerification, new { To = to, ActivationLink = verificationLink }, cancellationToken); + => SendMail(to, _templates.EmailVerification, new { To = to, VerifyLink = verificationLink }, cancellationToken); private async Task SendMail(Contact to, SmtpTemplate template, T data, diff --git a/Common/Constants/Constants.cs b/Common/Constants/Constants.cs index d9ee0a83..e8ffbbcd 100644 --- a/Common/Constants/Constants.cs +++ b/Common/Constants/Constants.cs @@ -7,6 +7,8 @@ public static class Duration public static readonly TimeSpan PasswordResetRequestLifetime = TimeSpan.FromHours(1); + public static readonly TimeSpan EmailChangeRequestLifetime = TimeSpan.FromHours(1); + public static readonly TimeSpan NameChangeCooldown = TimeSpan.FromDays(7); public static readonly TimeSpan LoginSessionLifetime = TimeSpan.FromDays(30); diff --git a/Common/Errors/AccountError.cs b/Common/Errors/AccountError.cs index 51c2968b..abd9576f 100644 --- a/Common/Errors/AccountError.cs +++ b/Common/Errors/AccountError.cs @@ -30,4 +30,16 @@ public static class AccountError public static OpenShockProblem AccountDeactivated => new OpenShockProblem("Account.Deactivated", "Your account has been deactivated", HttpStatusCode.Unauthorized); public static OpenShockProblem AccountOAuthOnly => new OpenShockProblem("Account.OAuthOnly", "This account is only accessible via OAuth", HttpStatusCode.Unauthorized); + + public static OpenShockProblem EmailChangeAlreadyInUse => new OpenShockProblem("Account.Email.AlreadyInUse", + "This email address is already in use", HttpStatusCode.Conflict); + + public static OpenShockProblem EmailChangeUnchanged => new OpenShockProblem("Account.Email.Unchanged", + "The new email address is the same as the current one", HttpStatusCode.BadRequest); + + public static OpenShockProblem EmailChangeTooMany => new OpenShockProblem("Account.Email.TooManyRequests", + "You have too many pending email change requests", HttpStatusCode.TooManyRequests); + + public static OpenShockProblem EmailChangeNotFound => new OpenShockProblem("Account.Email.VerifyNotFound", + "There is no email change request matching the supplied token", HttpStatusCode.BadRequest); } \ No newline at end of file diff --git a/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.Designer.cs b/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.Designer.cs new file mode 100644 index 00000000..d92ec70b --- /dev/null +++ b/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.Designer.cs @@ -0,0 +1,1487 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20260520153340_AddPasswordAndEmailVersions")] + partial class AddPasswordAndEmailVersions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used") + .HasDefaultValueSql("'-infinity'::timestamp without time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.PrimitiveCollection>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("EmailVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("email_version"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.Property("PasswordVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("password_version"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailVersionAtCreate") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("email_version_at_create"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("PasswordVersionAtCreate") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("password_version_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.cs b/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.cs new file mode 100644 index 00000000..6c05834d --- /dev/null +++ b/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class AddPasswordAndEmailVersions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "email_version", + table: "users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "password_version", + table: "users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "password_version_at_create", + table: "user_password_resets", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "email_version_at_create", + table: "user_email_changes", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "email_version", + table: "users"); + + migrationBuilder.DropColumn( + name: "password_version", + table: "users"); + + migrationBuilder.DropColumn( + name: "password_version_at_create", + table: "user_password_resets"); + + migrationBuilder.DropColumn( + name: "email_version_at_create", + table: "user_email_changes"); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 194008aa..a6653c57 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -21,7 +21,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); @@ -690,6 +690,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(320)") .HasColumnName("email"); + b.Property("EmailVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("email_version"); + b.Property("Name") .IsRequired() .HasMaxLength(32) @@ -703,6 +709,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("password_hash") .UseCollation("C"); + b.Property("PasswordVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("password_version"); + b.PrimitiveCollection>("Roles") .IsRequired() .HasColumnType("role_type[]") @@ -798,6 +810,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("created_at") .HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property("EmailVersionAtCreate") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("email_version_at_create"); + b.Property("NewEmail") .IsRequired() .HasMaxLength(320) @@ -953,6 +971,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("created_at") .HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property("PasswordVersionAtCreate") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("password_version_at_create"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(100) diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 2ad4b77f..b8ebce0b 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -303,6 +303,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .UseCollation("C") .VarCharWithLength(HardLimits.PasswordResetSecretMaxLength) .HasColumnName("token_hash"); + entity.Property(e => e.PasswordVersionAtCreate) + .HasDefaultValue(0) + .HasColumnName("password_version_at_create"); entity.Property(e => e.UsedAt) .HasColumnName("used_at"); entity.Property(e => e.UserId).HasColumnName("user_id"); @@ -606,6 +609,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .UseCollation("C") .VarCharWithLength(HardLimits.PasswordHashMaxLength) .HasColumnName("password_hash"); + entity.Property(e => e.PasswordVersion) + .HasDefaultValue(0) + .HasColumnName("password_version"); + entity.Property(e => e.EmailVersion) + .HasDefaultValue(0) + .HasColumnName("email_version"); entity.Property(e => e.Roles) .HasColumnType("role_type[]") .HasColumnName("roles"); @@ -719,6 +728,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .UseCollation("C") .VarCharWithLength(HardLimits.UserEmailChangeSecretMaxLength) .HasColumnName("token_hash"); + entity.Property(e => e.EmailVersionAtCreate) + .HasDefaultValue(0) + .HasColumnName("email_version_at_create"); entity.Property(e => e.UsedAt) .HasColumnName("used_at"); entity.Property(e => e.CreatedAt) diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index 9594ecfc..76d8bf18 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -12,6 +12,20 @@ public sealed class User public string? PasswordHash { get; set; } + /// + /// Monotonically increasing version bumped whenever is changed. + /// Snapshotted onto outstanding password reset requests so that any pending reset becomes + /// unusable as soon as the password changes through any path. + /// + public int PasswordVersion { get; set; } + + /// + /// Monotonically increasing version bumped whenever is changed. + /// Snapshotted onto outstanding email change requests so that any pending change becomes + /// unusable as soon as the email changes through any path. + /// + public int EmailVersion { get; set; } + public List Roles { get; set; } = []; public DateTime CreatedAt { get; set; } diff --git a/Common/OpenShockDb/UserEmailChange.cs b/Common/OpenShockDb/UserEmailChange.cs index 04040269..143db4d6 100644 --- a/Common/OpenShockDb/UserEmailChange.cs +++ b/Common/OpenShockDb/UserEmailChange.cs @@ -1,19 +1,58 @@ namespace OpenShock.Common.OpenShockDb; +/// +/// A pending or completed email-change request. The row is created when an authenticated user +/// asks to change their email, and consumed when they follow the verification link sent to the +/// new address. The user's is only updated on consumption. +/// public sealed class UserEmailChange { + /// + /// Internal identifier. Unlike password resets, the email-change link only carries the secret + /// token — the id is not exposed in the URL. + /// public required Guid Id { get; set; } + /// + /// User this email change belongs to. + /// public required Guid UserId { get; set; } + /// + /// The user's email address at the time the request was created. Preserved for audit; not + /// part of the verification predicate (the version counter handles that). + /// public required string OldEmail { get; set; } + /// + /// The email address the user wants to switch to. Written to when + /// the verification link is followed. + /// public required string NewEmail { get; set; } + /// + /// Hash of the secret token sent to . The plaintext token is never + /// stored — it only exists in the verification email and in the request the user submits. + /// public required string TokenHash { get; set; } + /// + /// Snapshot of at the moment this request was created. + /// The change is only valid while this still matches the user's current email version, so any + /// other email change going through first silently invalidates this one. + /// + public required int EmailVersionAtCreate { get; set; } + + /// + /// When the verification link was followed and the email was actually changed. Null while the + /// request is still pending. + /// public DateTime? UsedAt { get; set; } + /// + /// When the request was created. Combined with + /// to enforce the link's expiry. + /// public DateTime CreatedAt { get; set; } // Navigations diff --git a/Common/OpenShockDb/UserPasswordReset.cs b/Common/OpenShockDb/UserPasswordReset.cs index 6a936bbc..52387139 100644 --- a/Common/OpenShockDb/UserPasswordReset.cs +++ b/Common/OpenShockDb/UserPasswordReset.cs @@ -1,15 +1,44 @@ namespace OpenShock.Common.OpenShockDb; +/// +/// A pending or completed password reset request, created when a user (or someone with knowledge +/// of their email) initiates the reset flow. The row is consumed when the user follows the +/// emailed link and submits a new password. +/// public sealed class UserPasswordReset { + /// + /// Public identifier embedded in the reset URL alongside the secret token. + /// public required Guid Id { get; set; } + /// + /// User this reset request belongs to. + /// public required Guid UserId { get; set; } + /// + /// Hash of the secret token that was sent in the reset email. The plaintext token is never + /// stored — it only exists in the email link and in the request the user submits. + /// public required string TokenHash { get; set; } + /// + /// Snapshot of at the moment this request was created. + /// The reset is only valid while this still matches the user's current password version, so + /// any password change through any path silently invalidates every outstanding reset link. + /// + public required int PasswordVersionAtCreate { get; set; } + + /// + /// When the reset was consumed. Null while the request is still pending. + /// public DateTime? UsedAt { get; set; } + /// + /// When the reset was created. Combined with + /// to enforce the link's expiry. + /// public DateTime CreatedAt { get; set; } // Navigations diff --git a/Cron/Jobs/ClearOldEmailChangesJob.cs b/Cron/Jobs/ClearOldEmailChangesJob.cs new file mode 100644 index 00000000..99d867ba --- /dev/null +++ b/Cron/Jobs/ClearOldEmailChangesJob.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; +using OpenShock.Cron.Attributes; + +namespace OpenShock.Cron.Jobs; + +/// +/// Deletes old email change requests once they have expired their lifetime and haven't been used. +/// +[CronJob("0 0 * * *")] // Every day at midnight (https://crontab.guru/) +public sealed class ClearOldEmailChangesJob +{ + private readonly OpenShockContext _db; + private readonly ILogger _logger; + + public ClearOldEmailChangesJob(OpenShockContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task Execute() + { + var expiredAtUtc = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; + var earliestCreatedOnUtc = expiredAtUtc - Duration.AuditRetentionTime; + + int nDeleted = await _db.UserEmailChanges + .Where(x => x.UsedAt == null && x.CreatedAt < earliestCreatedOnUtc) + .ExecuteDeleteAsync(); + + _logger.LogInformation("Deleted {deletedCount} expired email change requests since {earliestCreatedOnUtc}", nDeleted, earliestCreatedOnUtc); + + return nDeleted; + } +} From eff7307b5083d7f080f1ada0508584fc710fe8ae Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 20 May 2026 18:09:07 +0200 Subject: [PATCH 02/15] try to fix flaky tests --- API.IntegrationTests/Helpers/MailpitHelper.cs | 52 ++++++++-- API.IntegrationTests/Helpers/TestHelper.cs | 20 ++++ API.IntegrationTests/Tests/MailTests.cs | 98 +++++++++---------- 3 files changed, 110 insertions(+), 60 deletions(-) diff --git a/API.IntegrationTests/Helpers/MailpitHelper.cs b/API.IntegrationTests/Helpers/MailpitHelper.cs index 08af81e2..ad2ee0b7 100644 --- a/API.IntegrationTests/Helpers/MailpitHelper.cs +++ b/API.IntegrationTests/Helpers/MailpitHelper.cs @@ -19,6 +19,11 @@ public MailpitHelper(string apiBaseUrl) /// Polls until at least one email arrives for the given recipient address, or the timeout elapses. /// Returns null if no message arrived within the timeout. /// + /// + /// Uses Mailpit's server-side search so the lookup is unaffected by how many unrelated messages + /// have accumulated in the inbox. Listing endpoints page at 50 by default which silently hid + /// matches once enough emails piled up across a test session. + /// public async Task WaitForMessageAsync( string toAddress, TimeSpan? timeout = null, @@ -27,20 +32,49 @@ public MailpitHelper(string apiBaseUrl) var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15)); while (DateTime.UtcNow < deadline && !cancellationToken.IsCancellationRequested) { - var response = await _client.GetFromJsonAsync( - "/api/v1/messages?limit=50", cancellationToken); - - var match = response?.Messages?.FirstOrDefault(m => - m.To?.Any(c => c.Address.Equals(toAddress, StringComparison.OrdinalIgnoreCase)) == true); - - if (match is not null) - return match; - + var match = (await SearchByRecipientAsync(toAddress, limit: 1, cancellationToken)).FirstOrDefault(); + if (match is not null) return match; await Task.Delay(300, cancellationToken); } return null; } + /// + /// Polls until at least emails are present for the recipient, or + /// the timeout elapses. Useful when a test expects multiple emails to arrive (e.g. two reset + /// requests in a row) and needs to disambiguate them. + /// + public async Task> WaitForMessagesAsync( + string toAddress, + int minCount, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15)); + while (DateTime.UtcNow < deadline && !cancellationToken.IsCancellationRequested) + { + var matches = await SearchByRecipientAsync(toAddress, limit: Math.Max(minCount, 10), cancellationToken); + if (matches.Count >= minCount) return matches; + await Task.Delay(300, cancellationToken); + } + return await SearchByRecipientAsync(toAddress, limit: Math.Max(minCount, 10), cancellationToken); + } + + /// + /// Returns all messages currently in Mailpit addressed to the given recipient. + /// Server-side filtered via the Mailpit search API. + /// + public async Task> SearchByRecipientAsync( + string toAddress, + int limit = 10, + CancellationToken cancellationToken = default) + { + var query = Uri.EscapeDataString($"to:{toAddress}"); + var response = await _client.GetFromJsonAsync( + $"/api/v1/search?query={query}&limit={limit}", cancellationToken); + return response?.Messages ?? []; + } + /// /// Returns all messages in Mailpit (no filtering). /// diff --git a/API.IntegrationTests/Helpers/TestHelper.cs b/API.IntegrationTests/Helpers/TestHelper.cs index 8591f669..d0c4072f 100644 --- a/API.IntegrationTests/Helpers/TestHelper.cs +++ b/API.IntegrationTests/Helpers/TestHelper.cs @@ -170,6 +170,26 @@ public static StringContent JsonContent(object obj) { return new StringContent(JsonSerializer.Serialize(obj, JsonOptions), Encoding.UTF8, "application/json"); } + + /// + /// Generates a unique recipient address so per-test Mailpit lookups never collide with other + /// tests' emails. Format: {prefix}-{8hex}@test.org. + /// + public static string UniqueEmail(string prefix) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + return $"{prefix}-{suffix}@test.org"; + } + + /// + /// Generates a unique username so concurrent tests in the same session never collide on the + /// users.name unique index. + /// + public static string UniqueUsername(string prefix) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + return ($"{prefix}{suffix}").ToLowerInvariant(); + } } public sealed record AuthenticatedUser(Guid Id, string Username, string Email, string SessionToken); diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index e4b063fa..92cc40fb 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -9,7 +9,8 @@ namespace OpenShock.API.IntegrationTests.Tests; /// /// Tests that verify emails are actually delivered via SMTP to Mailpit. -/// Each test uses a unique email address so messages can be filtered by recipient. +/// Each test uses a unique recipient address via so Mailpit +/// lookups never collide with other tests in the session. /// public sealed partial class MailTests { @@ -21,13 +22,14 @@ public sealed partial class MailTests [Test] public async Task V2Signup_SendsAccountActivationEmail() { - const string email = "mail-activation@test.org"; + var email = TestHelper.UniqueEmail("mail-activation"); + var username = TestHelper.UniqueUsername("mailactivation"); using var mailpit = WebApplicationFactory.CreateMailpitHelper(); using var client = WebApplicationFactory.CreateClient(); var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new { - username = "mailactivationuser", + username, password = "SecurePassword123#", email, turnstileResponse = "valid-token" @@ -43,14 +45,15 @@ public async Task V2Signup_SendsAccountActivationEmail() [Test] public async Task ActivationFlow_ViaEmailLink_ActivatesAccount() { - const string email = "mail-activate-flow@test.org"; + var email = TestHelper.UniqueEmail("mail-activate-flow"); + var username = TestHelper.UniqueUsername("mailactivateflow"); using var mailpit = WebApplicationFactory.CreateMailpitHelper(); using var client = WebApplicationFactory.CreateClient(); // Sign up — this triggers an activation email var signupResponse = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new { - username = "mailactivateflowuser", + username, password = "SecurePassword123#", email, turnstileResponse = "valid-token" @@ -85,16 +88,14 @@ public async Task ActivationFlow_ViaEmailLink_ActivatesAccount() [Test] public async Task V1PasswordReset_SendsPasswordResetEmail() { - const string email = "mail-pwreset@test.org"; + var email = TestHelper.UniqueEmail("mail-pwreset"); + var username = TestHelper.UniqueUsername("mailpwreset"); using var mailpit = WebApplicationFactory.CreateMailpitHelper(); - await TestHelper.CreateUserInDb(WebApplicationFactory, "mailpwresetuser", email, "OldPassword123#"); + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new - { - email - })); + var response = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); @@ -106,10 +107,11 @@ public async Task V1PasswordReset_SendsPasswordResetEmail() [Test] public async Task V2PasswordReset_SendsPasswordResetEmail() { - const string email = "mail-pwreset-v2@test.org"; + var email = TestHelper.UniqueEmail("mail-pwreset-v2"); + var username = TestHelper.UniqueUsername("mailpwresetv2"); using var mailpit = WebApplicationFactory.CreateMailpitHelper(); - await TestHelper.CreateUserInDb(WebApplicationFactory, "mailpwresetv2user", email, "OldPassword123#"); + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); using var client = WebApplicationFactory.CreateClient(); var response = await client.PostAsync("/2/account/reset-password", TestHelper.JsonContent(new @@ -128,11 +130,12 @@ public async Task V2PasswordReset_SendsPasswordResetEmail() [Test] public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword() { - const string email = "mail-pwreset-flow@test.org"; + var email = TestHelper.UniqueEmail("mail-pwreset-flow"); + var username = TestHelper.UniqueUsername("mailpwresetflow"); const string newPassword = "NewSecurePassword456#"; using var mailpit = WebApplicationFactory.CreateMailpitHelper(); - await TestHelper.CreateUserInDb(WebApplicationFactory, "mailpwresetflowuser", email, "OldPassword123#"); + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); using var client = WebApplicationFactory.CreateClient(); @@ -177,12 +180,13 @@ public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword() [Test] public async Task ChangeEmailFlow_ViaEmailLink_ChangesEmail() { - const string oldEmail = "mail-chgemail-flow@test.org"; - const string newEmail = "mail-chgemail-flow-new@test.org"; + var oldEmail = TestHelper.UniqueEmail("mail-chgemail-flow-old"); + var newEmail = TestHelper.UniqueEmail("mail-chgemail-flow-new"); + var username = TestHelper.UniqueUsername("mailchgemailflow"); const string password = "SecurePassword123#"; using var mailpit = WebApplicationFactory.CreateMailpitHelper(); - var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "mailchgemailflow", oldEmail, password); + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, oldEmail, password); using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); // Initiate the email change @@ -232,11 +236,12 @@ public async Task ChangeEmailFlow_ViaEmailLink_ChangesEmail() [Test] public async Task ChangeEmail_WrongPassword_Returns403_AndSendsNoEmail() { - const string oldEmail = "mail-chgemail-badpwd@test.org"; - const string newEmail = "mail-chgemail-badpwd-new@test.org"; + var oldEmail = TestHelper.UniqueEmail("mail-chgemail-badpwd-old"); + var newEmail = TestHelper.UniqueEmail("mail-chgemail-badpwd-new"); + var username = TestHelper.UniqueUsername("mailchgemailbadpwd"); using var mailpit = WebApplicationFactory.CreateMailpitHelper(); - var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "mailchgemailbadpwd", oldEmail, "CorrectPassword123#"); + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, oldEmail, "CorrectPassword123#"); using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); var response = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new @@ -255,13 +260,14 @@ public async Task ChangeEmail_WrongPassword_Returns403_AndSendsNoEmail() [Test] public async Task ChangeEmailFlow_SecondPendingRequest_InvalidatedAfterFirstCompletes() { - const string oldEmail = "mail-chgemail-sibling@test.org"; - const string firstNewEmail = "mail-chgemail-sibling-first@test.org"; - const string secondNewEmail = "mail-chgemail-sibling-second@test.org"; + var oldEmail = TestHelper.UniqueEmail("mail-chgemail-sibling-old"); + var firstNewEmail = TestHelper.UniqueEmail("mail-chgemail-sibling-first"); + var secondNewEmail = TestHelper.UniqueEmail("mail-chgemail-sibling-second"); + var username = TestHelper.UniqueUsername("mailchgemailsibling"); const string password = "SecurePassword123#"; using var mailpit = WebApplicationFactory.CreateMailpitHelper(); - var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "mailchgemailsibling", oldEmail, password); + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, oldEmail, password); using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); // Initiate two concurrent email change requests @@ -297,7 +303,7 @@ public async Task ChangeEmailFlow_SecondPendingRequest_InvalidatedAfterFirstComp var firstVerify = await anonClient.PostAsync($"/1/account/verify-email?token={firstToken}", null); await Assert.That(firstVerify.StatusCode).IsEqualTo(HttpStatusCode.OK); - // Second pending request must now be unusable because its OldEmail no longer matches the user's current email + // Second pending request is now invalid: its EmailVersionAtCreate snapshot no longer matches User.EmailVersion. var secondVerify = await anonClient.PostAsync($"/1/account/verify-email?token={secondToken}", null); await Assert.That(secondVerify.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); @@ -310,41 +316,29 @@ public async Task ChangeEmailFlow_SecondPendingRequest_InvalidatedAfterFirstComp [Test] public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompletes() { - const string email = "mail-pwreset-sibling@test.org"; + var email = TestHelper.UniqueEmail("mail-pwreset-sibling"); + var username = TestHelper.UniqueUsername("mailpwresetsibling"); const string firstNewPassword = "FirstNewPassword123#"; const string secondNewPassword = "SecondNewPassword456#"; using var mailpit = WebApplicationFactory.CreateMailpitHelper(); - await TestHelper.CreateUserInDb(WebApplicationFactory, "mailpwresetsibling", email, "OldPassword123#"); + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); using var client = WebApplicationFactory.CreateClient(); - // Initiate two password reset requests + // Fire two reset requests back-to-back, then wait for both emails to land. var firstInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); await Assert.That(firstInit.StatusCode).IsEqualTo(HttpStatusCode.OK); - // Wait for first message before triggering second so we can distinguish them - var firstMessage = await mailpit.WaitForMessageAsync(email); - await Assert.That(firstMessage).IsNotNull(); - var secondInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); await Assert.That(secondInit.StatusCode).IsEqualTo(HttpStatusCode.OK); - // Find the second (newer) message - MailpitHelper.MailpitMessage? secondMessage = null; - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); - while (DateTime.UtcNow < deadline && secondMessage is null) - { - var all = await mailpit.GetAllMessagesAsync(); - secondMessage = all.FirstOrDefault(m => - m.Id != firstMessage!.Id && - m.To?.Any(t => t.Address.Equals(email, StringComparison.OrdinalIgnoreCase)) == true); - if (secondMessage is null) await Task.Delay(300); - } - await Assert.That(secondMessage).IsNotNull(); + var messages = await mailpit.WaitForMessagesAsync(email, minCount: 2); + await Assert.That(messages.Count).IsGreaterThanOrEqualTo(2); - var firstFull = await mailpit.GetMessageAsync(firstMessage!.Id); - var secondFull = await mailpit.GetMessageAsync(secondMessage!.Id); + // Mailpit returns messages in reverse-chronological order; take the two for our recipient. + var firstFull = await mailpit.GetMessageAsync(messages[1].Id); + var secondFull = await mailpit.GetMessageAsync(messages[0].Id); var (firstResetId, firstSecret) = ExtractPasswordResetParams(firstFull!.Html); var (secondResetId, secondSecret) = ExtractPasswordResetParams(secondFull!.Html); await Assert.That(firstResetId).IsNotNull().And.IsNotEmpty(); @@ -378,12 +372,14 @@ public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompl [Test] public async Task ChangeEmail_AlreadyInUse_Returns409() { - const string takenEmail = "mail-chgemail-taken-existing@test.org"; - const string ownEmail = "mail-chgemail-taken-own@test.org"; + var takenEmail = TestHelper.UniqueEmail("mail-chgemail-taken-existing"); + var ownEmail = TestHelper.UniqueEmail("mail-chgemail-taken-own"); + var takenUser = TestHelper.UniqueUsername("mailchgemailtaken1"); + var ownUser = TestHelper.UniqueUsername("mailchgemailtaken2"); const string password = "SecurePassword123#"; - await TestHelper.CreateUserInDb(WebApplicationFactory, "mailchgemailtaken1", takenEmail, password); - var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "mailchgemailtaken2", ownEmail, password); + await TestHelper.CreateUserInDb(WebApplicationFactory, takenUser, takenEmail, password); + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, ownUser, ownEmail, password); using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); var response = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new From c2374e20cbac012c0139af529b08475f9f58d6a6 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 20 May 2026 18:11:23 +0200 Subject: [PATCH 03/15] handle email race in verify --- API/Services/Account/AccountService.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 60b6c6d4..ef213825 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -590,8 +590,18 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc change.User.Email = change.NewEmail; change.User.EmailVersion++; // Predicate-bound: every other pending change for this user is now invalid. - await _db.SaveChangesAsync(cancellationToken); - return true; + try + { + await _db.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) + { + // Another account claimed this email between request creation and verification. + // The pending row stays as-is (not marked used) so it can expire naturally; the user + // gets a 4xx from the controller instead of a 500. + return false; + } } private async Task CheckPassword(string password, User user) From da21d9a143a121778a80d409755f8b5e5ad7c976 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 21 May 2026 15:59:15 +0200 Subject: [PATCH 04/15] distinguish email conflict from not-found on verify, fix unreachable throws TryVerifyEmailAsync now returns OneOf instead of bool, so the 23505 race (another account claims the new address between request creation and verification) surfaces as 409 Conflict rather than 400 VerifyNotFound. Update VerifyEmail controller to match on the richer result type and add the ProducesResponseType annotation for 409. Replace throw new Exception with throw new UnreachableException in ChangeEmail and ChangePassword controllers. --- API/Controller/Account/Authenticated/ChangeEmail.cs | 3 ++- .../Account/Authenticated/ChangePassword.cs | 7 ++++--- API/Controller/Account/VerifyEmail.cs | 11 ++++++++--- API/Services/Account/AccountService.cs | 11 +++++------ API/Services/Account/IAccountService.cs | 5 ++++- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index 6e1a7a6f..261a0bf0 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; @@ -36,6 +37,6 @@ public async Task ChangeEmail([FromBody] ChangeEmailRequest body) unchanged => Problem(AccountError.EmailChangeUnchanged), tooMany => Problem(AccountError.EmailChangeTooMany), deactivated => Problem(AccountError.AccountDeactivated), - notFound => throw new Exception("Unexpected result, apparently our current user does not exist...")); + notFound => throw new UnreachableException("Authenticated user not found in database")); } } diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index ee79e22c..6ea249f8 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -1,4 +1,5 @@ -using System.Net.Mime; +using System.Diagnostics; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; @@ -32,7 +33,7 @@ public async Task ChangePassword([FromBody] ChangePasswordRequest return result.Match( success => Ok(), deactivated => Problem(AccountError.AccountDeactivated), - notFound => throw new Exception("Unexpected result, apparently our current user does not exist...") - ); + notFound => throw new UnreachableException("Authenticated user not found in database")); + } } \ No newline at end of file diff --git a/API/Controller/Account/VerifyEmail.cs b/API/Controller/Account/VerifyEmail.cs index 091d1f54..4d23bbd4 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -12,15 +12,20 @@ public sealed partial class AccountController /// Verify a pending email change using the token from the verification email. /// /// Email change verified and applied - /// Token is invalid or the request has expired + /// Token is invalid, already used, or the request has expired + /// The new email address was claimed by another account before verification completed [HttpPost("verify-email")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] + [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("1")] public async Task EmailVerify([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) { - bool ok = await _accountService.TryVerifyEmailAsync(token, cancellationToken); + var result = await _accountService.TryVerifyEmailAsync(token, cancellationToken); - return ok ? Ok() : Problem(AccountError.EmailChangeNotFound); + return result.Match( + success => Ok(), + notFound => Problem(AccountError.EmailChangeNotFound), + emailTaken => Problem(AccountError.EmailChangeAlreadyInUse)); } } \ No newline at end of file diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index ef213825..04ae0347 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -573,7 +573,7 @@ await _emailService.VerifyEmail(new Contact(lowerCaseEmail, data.User.Name), return new Success(); } - public async Task TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default) + public async Task> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default) { var hash = HashingUtils.HashToken(token); var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; @@ -584,7 +584,7 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc && x.EmailVersionAtCreate == x.User.EmailVersion && x.User.UserDeactivation == null && x.User.ActivatedAt != null, cancellationToken); - if (change is null) return false; + if (change is null) return new NotFound(); change.UsedAt = DateTime.UtcNow; change.User.Email = change.NewEmail; @@ -593,14 +593,13 @@ public async Task TryVerifyEmailAsync(string token, CancellationToken canc try { await _db.SaveChangesAsync(cancellationToken); - return true; + return new Success(); } catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) { // Another account claimed this email between request creation and verification. - // The pending row stays as-is (not marked used) so it can expire naturally; the user - // gets a 4xx from the controller instead of a 500. - return false; + // The pending row stays as-is (not marked used) so it can expire naturally. + return new EmailAlreadyInUse(); } } diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index df530a81..a7f0a42d 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -127,11 +127,14 @@ public interface IAccountService /// /// Verifies a pending email change using the supplied token. On success the user's email is updated. + /// Returns when the token is invalid, expired, or already used. + /// Returns when the new address was claimed by another account between + /// request creation and verification (race condition). /// /// /// /// - Task TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); + Task> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); } public readonly struct AccountIsOAuthOnly; From dc6dd0909a54ea040f5323d9dc0a4bc0de823fe1 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 21 May 2026 22:19:50 +0200 Subject: [PATCH 05/15] clean up account flow routes and notify old email on change Rename to consistent resource paths: POST /2/account/reset-password -> /2/account/password-reset HEAD /1/account/recover/{id}/{secret} -> GET /1/account/password-reset/{id}/{secret} POST /1/account/recover/{id}/{secret} -> POST /1/account/password-reset/{id}/{secret}/complete POST /1/account/verify-email -> POST /1/account/email-change/verify POST /1/account/email (authenticated) -> POST /1/account/email-change Switch the reset-validity check from HEAD to GET so it can carry a body and isn't subject to proxy quirks around HEAD. Send an informational notice to the user's previous address when an email change is initiated, alongside the verification email to the new address. The notice contains no action link. Best-effort: a send failure is logged but does not roll back the request. Integration tests cover the new notice path, the unchanged-email rejection, and a direct 404 on the renamed reset-check endpoint, plus the existing flows now exercise the renamed routes end-to-end. --- API.IntegrationTests/Tests/MailTests.cs | 118 +++++++++++++++--- .../Account/Authenticated/ChangeEmail.cs | 2 +- .../Account/PasswordResetCheckValid.cs | 2 +- .../Account/PasswordResetComplete.cs | 2 +- .../Account/PasswordResetInitiateV2.cs | 2 +- API/Controller/Account/VerifyEmail.cs | 2 +- API/Options/MailJetOptions.cs | 3 + API/Services/Account/AccountService.cs | 13 ++ API/Services/Email/IEmailService.cs | 10 ++ .../Email/Mailjet/MailjetEmailService.cs | 16 +++ API/Services/Email/NoneEmailService.cs | 6 + API/Services/Email/Smtp/SmtpEmailService.cs | 4 + .../Email/Smtp/SmtpEmailServiceExtension.cs | 3 +- .../Email/Smtp/SmtpServiceTemplates.cs | 1 + API/SmtpTemplates/EmailChangeNotice.liquid | 51 ++++++++ 15 files changed, 209 insertions(+), 26 deletions(-) create mode 100644 API/SmtpTemplates/EmailChangeNotice.liquid diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 92cc40fb..970500f5 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -114,7 +114,7 @@ public async Task V2PasswordReset_SendsPasswordResetEmail() await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/2/account/reset-password", TestHelper.JsonContent(new + var response = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" @@ -156,13 +156,12 @@ public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword() await Assert.That(secret).IsNotNull().And.IsNotEmpty(); // Verify the reset token is valid - var checkResponse = await client.SendAsync(new HttpRequestMessage( - HttpMethod.Head, $"/1/account/recover/{resetId}/{secret}")); + var checkResponse = await client.GetAsync($"/1/account/password-reset/{resetId}/{secret}"); await Assert.That(checkResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); // Complete the reset with a new password var completeResponse = await client.PostAsync( - $"/1/account/recover/{resetId}/{secret}", + $"/1/account/password-reset/{resetId}/{secret}/complete", TestHelper.JsonContent(new { password = newPassword })); await Assert.That(completeResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); @@ -190,7 +189,7 @@ public async Task ChangeEmailFlow_ViaEmailLink_ChangesEmail() using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); // Initiate the email change - var initiateResponse = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new + var initiateResponse = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new { currentPassword = password, email = newEmail @@ -217,7 +216,7 @@ public async Task ChangeEmailFlow_ViaEmailLink_ChangesEmail() // Use the token to complete the change using var anonClient = WebApplicationFactory.CreateClient(); - var verifyResponse = await anonClient.PostAsync($"/1/account/verify-email?token={token}", null); + var verifyResponse = await anonClient.PostAsync($"/1/account/email-change/verify?token={token}", null); await Assert.That(verifyResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); // Email is now updated @@ -229,7 +228,7 @@ public async Task ChangeEmailFlow_ViaEmailLink_ChangesEmail() } // Re-using the same token must now fail - var replayResponse = await anonClient.PostAsync($"/1/account/verify-email?token={token}", null); + var replayResponse = await anonClient.PostAsync($"/1/account/email-change/verify?token={token}", null); await Assert.That(replayResponse.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); } @@ -244,7 +243,7 @@ public async Task ChangeEmail_WrongPassword_Returns403_AndSendsNoEmail() var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, oldEmail, "CorrectPassword123#"); using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); - var response = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new + var response = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new { currentPassword = "WrongPassword!", email = newEmail @@ -252,9 +251,88 @@ public async Task ChangeEmail_WrongPassword_Returns403_AndSendsNoEmail() await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); - // No verification email should have been dispatched - var message = await mailpit.WaitForMessageAsync(newEmail, TimeSpan.FromSeconds(2)); - await Assert.That(message).IsNull(); + // Neither the verification (new address) nor the change notice (old address) should have been dispatched. + var verification = await mailpit.WaitForMessageAsync(newEmail, TimeSpan.FromSeconds(2)); + await Assert.That(verification).IsNull(); + var notice = await mailpit.WaitForMessageAsync(oldEmail, TimeSpan.FromSeconds(2)); + await Assert.That(notice).IsNull(); + } + + [Test] + public async Task ChangeEmailFlow_SendsNoticeToOldEmail() + { + var oldEmail = TestHelper.UniqueEmail("mail-chgemail-notice-old"); + var newEmail = TestHelper.UniqueEmail("mail-chgemail-notice-new"); + var username = TestHelper.UniqueUsername("mailchgemailnotice"); + const string password = "SecurePassword123#"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, oldEmail, password); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var initiateResponse = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new + { + currentPassword = password, + email = newEmail + })); + await Assert.That(initiateResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Verification email lands at the new address... + var verification = await mailpit.WaitForMessageAsync(newEmail); + await Assert.That(verification).IsNotNull(); + await Assert.That(verification!.To?.Select(c => c.Address)).Contains(newEmail); + + // ...and a notice lands at the OLD address, mentioning the new address. + var notice = await mailpit.WaitForMessageAsync(oldEmail); + await Assert.That(notice).IsNotNull(); + await Assert.That(notice!.To?.Select(c => c.Address)).Contains(oldEmail); + + var noticeFull = await mailpit.GetMessageAsync(notice.Id); + await Assert.That(noticeFull).IsNotNull(); + await Assert.That(noticeFull!.Html).Contains(newEmail); + + // The notice must not contain a verification link — it's informational only. + await Assert.That(noticeFull.Html).DoesNotContain("token="); + } + + [Test] + public async Task ChangeEmail_Unchanged_Returns400_AndSendsNoEmail() + { + var email = TestHelper.UniqueEmail("mail-chgemail-unchanged"); + var username = TestHelper.UniqueUsername("mailchgemailunchanged"); + const string password = "SecurePassword123#"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, email, password); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new + { + currentPassword = password, + email + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + + // No emails at all — neither verification nor notice. + var any = await mailpit.WaitForMessageAsync(email, TimeSpan.FromSeconds(2)); + await Assert.That(any).IsNull(); + } + + [Test] + public async Task PasswordResetCheck_InvalidToken_Returns404() + { + var email = TestHelper.UniqueEmail("mail-pwreset-check-invalid"); + var username = TestHelper.UniqueUsername("mailpwresetcheckinvalid"); + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + + var bogusId = Guid.NewGuid(); + const string bogusSecret = "thisisnotarealtokenatallzz"; + + var response = await client.GetAsync($"/1/account/password-reset/{bogusId}/{bogusSecret}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } [Test] @@ -271,14 +349,14 @@ public async Task ChangeEmailFlow_SecondPendingRequest_InvalidatedAfterFirstComp using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); // Initiate two concurrent email change requests - var firstInit = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new + var firstInit = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new { currentPassword = password, email = firstNewEmail })); await Assert.That(firstInit.StatusCode).IsEqualTo(HttpStatusCode.OK); - var secondInit = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new + var secondInit = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new { currentPassword = password, email = secondNewEmail @@ -300,11 +378,11 @@ public async Task ChangeEmailFlow_SecondPendingRequest_InvalidatedAfterFirstComp using var anonClient = WebApplicationFactory.CreateClient(); // Complete the first request — email becomes firstNewEmail - var firstVerify = await anonClient.PostAsync($"/1/account/verify-email?token={firstToken}", null); + var firstVerify = await anonClient.PostAsync($"/1/account/email-change/verify?token={firstToken}", null); await Assert.That(firstVerify.StatusCode).IsEqualTo(HttpStatusCode.OK); // Second pending request is now invalid: its EmailVersionAtCreate snapshot no longer matches User.EmailVersion. - var secondVerify = await anonClient.PostAsync($"/1/account/verify-email?token={secondToken}", null); + var secondVerify = await anonClient.PostAsync($"/1/account/email-change/verify?token={secondToken}", null); await Assert.That(secondVerify.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); @@ -346,17 +424,17 @@ public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompl // Complete the first reset var firstComplete = await client.PostAsync( - $"/1/account/recover/{firstResetId}/{firstSecret}", + $"/1/account/password-reset/{firstResetId}/{firstSecret}/complete", TestHelper.JsonContent(new { password = firstNewPassword })); await Assert.That(firstComplete.StatusCode).IsEqualTo(HttpStatusCode.OK); // The second (sibling) reset must no longer be usable - var secondCheck = await client.SendAsync(new HttpRequestMessage( - HttpMethod.Head, $"/1/account/recover/{secondResetId}/{secondSecret}")); + var secondCheck = await client.GetAsync( + $"/1/account/password-reset/{secondResetId}/{secondSecret}"); await Assert.That(secondCheck.StatusCode).IsEqualTo(HttpStatusCode.NotFound); var secondComplete = await client.PostAsync( - $"/1/account/recover/{secondResetId}/{secondSecret}", + $"/1/account/password-reset/{secondResetId}/{secondSecret}/complete", TestHelper.JsonContent(new { password = secondNewPassword })); await Assert.That(secondComplete.StatusCode).IsEqualTo(HttpStatusCode.NotFound); @@ -382,7 +460,7 @@ public async Task ChangeEmail_AlreadyInUse_Returns409() var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, ownUser, ownEmail, password); using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); - var response = await client.PostAsync("/1/account/email", TestHelper.JsonContent(new + var response = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new { currentPassword = password, email = takenEmail diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index 261a0bf0..12d0dda4 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -15,7 +15,7 @@ public sealed partial class AuthenticatedAccountController /// address; the change is not applied until that link is opened. /// /// - [HttpPost("email")] + [HttpPost("email-change")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // EmailChangeUnchanged diff --git a/API/Controller/Account/PasswordResetCheckValid.cs b/API/Controller/Account/PasswordResetCheckValid.cs index d66f869d..1aedaaed 100644 --- a/API/Controller/Account/PasswordResetCheckValid.cs +++ b/API/Controller/Account/PasswordResetCheckValid.cs @@ -18,7 +18,7 @@ public sealed partial class AccountController /// /// Valid password reset process /// Password reset process not found - [HttpHead("recover/{passwordResetId}/{secret}")] + [HttpGet("password-reset/{passwordResetId}/{secret}")] [EnableRateLimiting("auth")] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound diff --git a/API/Controller/Account/PasswordResetComplete.cs b/API/Controller/Account/PasswordResetComplete.cs index d9e58c93..911eae30 100644 --- a/API/Controller/Account/PasswordResetComplete.cs +++ b/API/Controller/Account/PasswordResetComplete.cs @@ -18,7 +18,7 @@ public sealed partial class AccountController /// /// Password successfully changed /// Password reset process not found - [HttpPost("recover/{passwordResetId}/{secret}")] + [HttpPost("password-reset/{passwordResetId}/{secret}/complete")] [EnableRateLimiting("auth")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 2c9e1a76..f43b052b 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -17,7 +17,7 @@ public sealed partial class AccountController /// Initiate a password reset /// /// Password reset email sent if the email is associated to an registered account - [HttpPost("reset-password")] + [HttpPost("password-reset")] [EnableRateLimiting("auth")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/API/Controller/Account/VerifyEmail.cs b/API/Controller/Account/VerifyEmail.cs index 4d23bbd4..2e6c2103 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -14,7 +14,7 @@ public sealed partial class AccountController /// Email change verified and applied /// Token is invalid, already used, or the request has expired /// The new email address was claimed by another account before verification completed - [HttpPost("verify-email")] + [HttpPost("email-change/verify")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] diff --git a/API/Options/MailJetOptions.cs b/API/Options/MailJetOptions.cs index a7a31f04..10074f4d 100644 --- a/API/Options/MailJetOptions.cs +++ b/API/Options/MailJetOptions.cs @@ -33,6 +33,9 @@ public sealed class MailjetTemplateOptions [Required] public required ulong VerifyEmailComplete { get; init; } + + [Required] + public required ulong EmailChangeNotice { get; init; } } } diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 04ae0347..09eb130d 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -570,6 +570,19 @@ public async Task /// public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default); + + /// + /// Informational notice sent to the user's previous email address when an email change is + /// initiated. Contains no action link — its only purpose is to alert the legitimate owner + /// of the address that a change request was started, in case the account was compromised. + /// + /// The old email address being notified. + /// The new email address that was requested. + /// + public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default); } diff --git a/API/Services/Email/Mailjet/MailjetEmailService.cs b/API/Services/Email/Mailjet/MailjetEmailService.cs index 2d88cd40..e64c3368 100644 --- a/API/Services/Email/Mailjet/MailjetEmailService.cs +++ b/API/Services/Email/Mailjet/MailjetEmailService.cs @@ -83,6 +83,22 @@ await SendMail(new TemplateMail }, cancellationToken); } + /// + public async Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default) + { + await SendMail(new TemplateMail + { + From = _sender, + Subject = "Your OpenShock email is being changed", + To = [to], + TemplateId = _options.Template.EmailChangeNotice, + Variables = new Dictionary + { + { "newEmail", newEmail }, + } + }, cancellationToken); + } + #endregion private Task SendMail(MailBase templateMail, CancellationToken cancellationToken = default) => SendMails([templateMail], cancellationToken); diff --git a/API/Services/Email/NoneEmailService.cs b/API/Services/Email/NoneEmailService.cs index 631154f0..e1908562 100644 --- a/API/Services/Email/NoneEmailService.cs +++ b/API/Services/Email/NoneEmailService.cs @@ -33,4 +33,10 @@ public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken canc _logger.LogError("Email verification email not sent, this is a noop implementation of the email service"); return Task.CompletedTask; } + + public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default) + { + _logger.LogError("Email change notice not sent, this is a noop implementation of the email service"); + return Task.CompletedTask; + } } \ No newline at end of file diff --git a/API/Services/Email/Smtp/SmtpEmailService.cs b/API/Services/Email/Smtp/SmtpEmailService.cs index c2d44f3e..ee2ab771 100644 --- a/API/Services/Email/Smtp/SmtpEmailService.cs +++ b/API/Services/Email/Smtp/SmtpEmailService.cs @@ -53,6 +53,10 @@ public Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellat public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default) => SendMail(to, _templates.EmailVerification, new { To = to, VerifyLink = verificationLink }, cancellationToken); + /// + public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default) + => SendMail(to, _templates.EmailChangeNotice, new { To = to, NewEmail = newEmail }, cancellationToken); + private async Task SendMail(Contact to, SmtpTemplate template, T data, CancellationToken cancellationToken = default) diff --git a/API/Services/Email/Smtp/SmtpEmailServiceExtension.cs b/API/Services/Email/Smtp/SmtpEmailServiceExtension.cs index 016f06e6..21fa8a1a 100644 --- a/API/Services/Email/Smtp/SmtpEmailServiceExtension.cs +++ b/API/Services/Email/Smtp/SmtpEmailServiceExtension.cs @@ -18,7 +18,8 @@ public static WebApplicationBuilder AddSmtpEmailService(this WebApplicationBuild { AccountActivation = SmtpTemplate.ParseFromFileThrow("SmtpTemplates/AccountActivation.liquid").Result, PasswordReset = SmtpTemplate.ParseFromFileThrow("SmtpTemplates/PasswordReset.liquid").Result, - EmailVerification = SmtpTemplate.ParseFromFileThrow("SmtpTemplates/EmailVerification.liquid").Result + EmailVerification = SmtpTemplate.ParseFromFileThrow("SmtpTemplates/EmailVerification.liquid").Result, + EmailChangeNotice = SmtpTemplate.ParseFromFileThrow("SmtpTemplates/EmailChangeNotice.liquid").Result }); builder.Services.AddSingleton(); diff --git a/API/Services/Email/Smtp/SmtpServiceTemplates.cs b/API/Services/Email/Smtp/SmtpServiceTemplates.cs index 9b7237f5..675b8006 100644 --- a/API/Services/Email/Smtp/SmtpServiceTemplates.cs +++ b/API/Services/Email/Smtp/SmtpServiceTemplates.cs @@ -5,4 +5,5 @@ public sealed class SmtpServiceTemplates public required SmtpTemplate AccountActivation { get; set; } public required SmtpTemplate PasswordReset { get; set; } public required SmtpTemplate EmailVerification { get; set; } + public required SmtpTemplate EmailChangeNotice { get; set; } } \ No newline at end of file diff --git a/API/SmtpTemplates/EmailChangeNotice.liquid b/API/SmtpTemplates/EmailChangeNotice.liquid new file mode 100644 index 00000000..ac096b64 --- /dev/null +++ b/API/SmtpTemplates/EmailChangeNotice.liquid @@ -0,0 +1,51 @@ +Your OpenShock email is being changed + + + + + + Email change requested + + + +
+

Email change requested

+

Hello {{ To.Name }},

+

Someone requested that the email address on your OpenShock account be changed to {{ NewEmail }}.

+

The change is not yet applied. It will only take effect once the new address is verified via the link sent to it.

+

If this was you, no action is needed.

+

If this was not you, sign in immediately and change your password — your account may be compromised.

+

Thank you,
OpenShock Team

+
+ + From af58d008a02ebe86b0764be9e5387bb5734261b1 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 21 May 2026 22:36:13 +0200 Subject: [PATCH 06/15] restore POST /recover/{id}/{secret} as deprecated alias The old completion route stays as a working alias for clients that already integrated against it. Both routes call the same handler; the legacy one is marked [Obsolete] so it shows up as deprecated in the generated OpenAPI spec. New endpoint: POST /1/account/password-reset/{id}/{secret}/complete Legacy alias: POST /1/account/recover/{id}/{secret} (deprecated) Integration test asserts the legacy route still completes a reset. --- API.IntegrationTests/Tests/MailTests.cs | 35 +++++++++++++++++++ .../Account/PasswordResetComplete.cs | 27 ++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 970500f5..36848499 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -319,6 +319,41 @@ public async Task ChangeEmail_Unchanged_Returns400_AndSendsNoEmail() await Assert.That(any).IsNull(); } + [Test] + public async Task PasswordResetComplete_LegacyRecoverRoute_StillWorks() + { + var email = TestHelper.UniqueEmail("mail-pwreset-legacy"); + var username = TestHelper.UniqueUsername("mailpwresetlegacy"); + const string newPassword = "LegacyNewPassword456#"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + + var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var message = await mailpit.WaitForMessageAsync(email); + await Assert.That(message).IsNotNull(); + var fullMessage = await mailpit.GetMessageAsync(message!.Id); + var (resetId, secret) = ExtractPasswordResetParams(fullMessage!.Html); + await Assert.That(resetId).IsNotNull().And.IsNotEmpty(); + + // Hit the deprecated route directly — must still complete the reset. + var completeResponse = await client.PostAsync( + $"/1/account/recover/{resetId}/{secret}", + TestHelper.JsonContent(new { password = newPassword })); + await Assert.That(completeResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new + { + email, + password = newPassword + })); + await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + [Test] public async Task PasswordResetCheck_InvalidToken_Returns404() { diff --git a/API/Controller/Account/PasswordResetComplete.cs b/API/Controller/Account/PasswordResetComplete.cs index 911eae30..2c211074 100644 --- a/API/Controller/Account/PasswordResetComplete.cs +++ b/API/Controller/Account/PasswordResetComplete.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using System; +using Microsoft.AspNetCore.Mvc; using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; @@ -24,8 +25,30 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound [MapToApiVersion("1")] - public async Task PasswordResetComplete([FromRoute] Guid passwordResetId, + public Task PasswordResetComplete([FromRoute] Guid passwordResetId, [FromRoute] string secret, [FromBody] PasswordResetProcessData body) + => CompletePasswordReset(passwordResetId, secret, body); + + /// + /// Complete a password reset process. Deprecated: use POST /password-reset/{id}/{secret}/complete instead. + /// + /// The id of the password reset + /// The secret of the password reset + /// + /// Password successfully changed + /// Password reset process not found + [Obsolete("Use POST /password-reset/{passwordResetId}/{secret}/complete instead.")] + [HttpPost("recover/{passwordResetId}/{secret}")] + [EnableRateLimiting("auth")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound + [MapToApiVersion("1")] + public Task PasswordResetCompleteLegacy([FromRoute] Guid passwordResetId, + [FromRoute] string secret, [FromBody] PasswordResetProcessData body) + => CompletePasswordReset(passwordResetId, secret, body); + + private async Task CompletePasswordReset(Guid passwordResetId, string secret, PasswordResetProcessData body) { var passwordResetComplete = await _accountService.CompletePasswordResetFlowAsync(passwordResetId, secret, body.Password); From aedfa176c45d785c7bec2262cc919b2daf19f171 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 21 May 2026 22:38:50 +0200 Subject: [PATCH 07/15] restore HEAD /recover/{id}/{secret} as deprecated alias Same treatment as the complete route: the legacy HEAD check stays as a working alias delegating to the same handler, marked [Obsolete] so it surfaces as deprecated in the OpenAPI spec. New endpoint: GET /1/account/password-reset/{id}/{secret} Legacy alias: HEAD /1/account/recover/{id}/{secret} (deprecated) Integration test confirms the legacy HEAD route still returns 200 for a valid pending reset. --- API.IntegrationTests/Tests/MailTests.cs | 23 +++++++++++++++++ .../Account/PasswordResetCheckValid.cs | 25 +++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 36848499..745a7261 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -354,6 +354,29 @@ public async Task PasswordResetComplete_LegacyRecoverRoute_StillWorks() await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); } + [Test] + public async Task PasswordResetCheck_LegacyHeadRecoverRoute_StillWorks() + { + var email = TestHelper.UniqueEmail("mail-pwreset-check-legacy"); + var username = TestHelper.UniqueUsername("mailpwresetchecklegacy"); + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var message = await mailpit.WaitForMessageAsync(email); + await Assert.That(message).IsNotNull(); + var fullMessage = await mailpit.GetMessageAsync(message!.Id); + var (resetId, secret) = ExtractPasswordResetParams(fullMessage!.Html); + + var legacyCheck = await client.SendAsync(new HttpRequestMessage( + HttpMethod.Head, $"/1/account/recover/{resetId}/{secret}")); + await Assert.That(legacyCheck.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + [Test] public async Task PasswordResetCheck_InvalidToken_Returns404() { diff --git a/API/Controller/Account/PasswordResetCheckValid.cs b/API/Controller/Account/PasswordResetCheckValid.cs index 1aedaaed..a509f94c 100644 --- a/API/Controller/Account/PasswordResetCheckValid.cs +++ b/API/Controller/Account/PasswordResetCheckValid.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using System; +using Microsoft.AspNetCore.Mvc; using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; @@ -23,7 +24,27 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound [MapToApiVersion("1")] - public async Task PasswordResetCheckValid([FromRoute] Guid passwordResetId, [FromRoute] string secret, CancellationToken cancellationToken) + public Task PasswordResetCheckValid([FromRoute] Guid passwordResetId, [FromRoute] string secret, CancellationToken cancellationToken) + => CheckPasswordReset(passwordResetId, secret, cancellationToken); + + /// + /// Check if a password reset is in progress. Deprecated: use GET /password-reset/{id}/{secret} instead. + /// + /// The id of the password reset + /// The secret of the password reset + /// + /// Valid password reset process + /// Password reset process not found + [Obsolete("Use GET /password-reset/{passwordResetId}/{secret} instead.")] + [HttpHead("recover/{passwordResetId}/{secret}")] + [EnableRateLimiting("auth")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound + [MapToApiVersion("1")] + public Task PasswordResetCheckValidLegacy([FromRoute] Guid passwordResetId, [FromRoute] string secret, CancellationToken cancellationToken) + => CheckPasswordReset(passwordResetId, secret, cancellationToken); + + private async Task CheckPasswordReset(Guid passwordResetId, string secret, CancellationToken cancellationToken) { var passwordResetExists = await _accountService.CheckPasswordResetExistsAsync(passwordResetId, secret, cancellationToken); return passwordResetExists.Match( From 4aa589933c9ee5f08869d402ff5d9f4e1266e5a7 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 21 May 2026 23:01:11 +0200 Subject: [PATCH 08/15] Change to SecurityStamp --- API.IntegrationTests/Helpers/TestHelper.cs | 4 +- API.IntegrationTests/Tests/MailTests.cs | 2 +- API.IntegrationTests/Tests/ShareLinksTests.cs | 2 +- .../Tests/SignalRUserHubTests.cs | 2 +- API/Services/Account/AccountService.cs | 16 ++--- ...60520153340_AddPasswordAndEmailVersions.cs | 62 ------------------- ...21205924_AddUserSecurityStamp.Designer.cs} | 40 +++++------- .../20260521205924_AddUserSecurityStamp.cs | 52 ++++++++++++++++ .../OpenShockContextModelSnapshot.cs | 36 ++++------- Common/OpenShockDb/OpenShockContext.cs | 19 +++--- Common/OpenShockDb/User.cs | 16 ++--- Common/OpenShockDb/UserEmailChange.cs | 8 +-- Common/OpenShockDb/UserPasswordReset.cs | 9 +-- 13 files changed, 114 insertions(+), 154 deletions(-) delete mode 100644 Common/Migrations/20260520153340_AddPasswordAndEmailVersions.cs rename Common/Migrations/{20260520153340_AddPasswordAndEmailVersions.Designer.cs => 20260521205924_AddUserSecurityStamp.Designer.cs} (98%) create mode 100644 Common/Migrations/20260521205924_AddUserSecurityStamp.cs diff --git a/API.IntegrationTests/Helpers/TestHelper.cs b/API.IntegrationTests/Helpers/TestHelper.cs index d0c4072f..71947eb2 100644 --- a/API.IntegrationTests/Helpers/TestHelper.cs +++ b/API.IntegrationTests/Helpers/TestHelper.cs @@ -177,7 +177,7 @@ public static StringContent JsonContent(object obj) /// public static string UniqueEmail(string prefix) { - var suffix = Guid.NewGuid().ToString("N")[..8]; + var suffix = Guid.CreateVersion7().ToString("N")[..8]; return $"{prefix}-{suffix}@test.org"; } @@ -187,7 +187,7 @@ public static string UniqueEmail(string prefix) /// public static string UniqueUsername(string prefix) { - var suffix = Guid.NewGuid().ToString("N")[..8]; + var suffix = Guid.CreateVersion7().ToString("N")[..8]; return ($"{prefix}{suffix}").ToLowerInvariant(); } } diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 745a7261..e2482a9b 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -386,7 +386,7 @@ public async Task PasswordResetCheck_InvalidToken_Returns404() using var client = WebApplicationFactory.CreateClient(); - var bogusId = Guid.NewGuid(); + var bogusId = Guid.CreateVersion7(); const string bogusSecret = "thisisnotarealtokenatallzz"; var response = await client.GetAsync($"/1/account/password-reset/{bogusId}/{bogusSecret}"); diff --git a/API.IntegrationTests/Tests/ShareLinksTests.cs b/API.IntegrationTests/Tests/ShareLinksTests.cs index c1bb57dd..0a2b022a 100644 --- a/API.IntegrationTests/Tests/ShareLinksTests.cs +++ b/API.IntegrationTests/Tests/ShareLinksTests.cs @@ -91,7 +91,7 @@ public async Task DeleteShareLink_NotFound() var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sharelinkdnf", "sharelinkdnf@test.org", "SecurePassword123#"); using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); - var response = await client.DeleteAsync($"/1/shares/links/{Guid.NewGuid()}"); + var response = await client.DeleteAsync($"/1/shares/links/{Guid.CreateVersion7()}"); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } diff --git a/API.IntegrationTests/Tests/SignalRUserHubTests.cs b/API.IntegrationTests/Tests/SignalRUserHubTests.cs index 2a1b1534..69e27c77 100644 --- a/API.IntegrationTests/Tests/SignalRUserHubTests.cs +++ b/API.IntegrationTests/Tests/SignalRUserHubTests.cs @@ -93,7 +93,7 @@ public async Task PublicShareHub_Negotiate_WithoutAuth_ReturnsOk() // PublicShareHub doesn't require auth at the negotiate level using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync($"/1/hubs/share/link/{Guid.NewGuid()}/negotiate?negotiateVersion=1", null); + var response = await client.PostAsync($"/1/hubs/share/link/{Guid.CreateVersion7()}/negotiate?negotiateVersion=1", null); // Should return OK (negotiate doesn't check auth or share existence) await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); } diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 09eb130d..3126eedb 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -382,7 +382,7 @@ public async Task> CheckPasswordResetExi var validSince = DateTime.UtcNow - Duration.PasswordResetRequestLifetime; var reset = await _db.UserPasswordResets.FirstOrDefaultAsync(x => x.Id == passwordResetId && x.UsedAt == null && x.CreatedAt >= validSince - && x.PasswordVersionAtCreate == x.User.PasswordVersion, + && x.SecurityStampAtCreate == x.User.SecurityStamp, cancellationToken: cancellationToken); if (reset is null) return new NotFound(); @@ -418,7 +418,7 @@ public async Task x.User) .Include(x => x.User.UserDeactivation) .FirstOrDefaultAsync(x => x.Id == passwordResetId && x.UsedAt == null && x.CreatedAt >= validSince - && x.PasswordVersionAtCreate == x.User.PasswordVersion); + && x.SecurityStampAtCreate == x.User.SecurityStamp); if (reset is null) return new NotFound(); if (reset.User.ActivatedAt is null) return new AccountNotActivated(); if (reset.User.UserDeactivation is not null) return new AccountDeactivated(); @@ -449,7 +449,7 @@ public async Task> ChangePasswordAs if (user.UserDeactivation is not null) return new AccountDeactivated(); user.PasswordHash = HashingUtils.HashPassword(newPassword); - user.PasswordVersion++; // Any outstanding reset row for this user has a stale PasswordVersionAtCreate after this — predicate handles invalidation. + user.SecurityStamp = Guid.CreateVersion7(); // Any outstanding reset/email-change row for this user has a stale SecurityStampAtCreate after this; predicate handles invalidation. await _db.SaveChangesAsync(); @@ -562,7 +562,7 @@ public async Task> TryVerifyEmailAsy var change = await _db.UserEmailChanges .Include(x => x.User).ThenInclude(u => u.UserDeactivation) .FirstOrDefaultAsync(x => x.TokenHash == hash && x.UsedAt == null && x.CreatedAt >= validSince - && x.EmailVersionAtCreate == x.User.EmailVersion + && x.SecurityStampAtCreate == x.User.SecurityStamp && x.User.UserDeactivation == null && x.User.ActivatedAt != null, cancellationToken); if (change is null) return new NotFound(); change.UsedAt = DateTime.UtcNow; change.User.Email = change.NewEmail; - change.User.EmailVersion++; // Predicate-bound: every other pending change for this user is now invalid. + change.User.SecurityStamp = Guid.CreateVersion7(); // Rotates the stamp; every other pending reset/email-change for this user is now invalid by predicate. try { diff --git a/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.cs b/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.cs deleted file mode 100644 index 6c05834d..00000000 --- a/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OpenShock.Common.Migrations -{ - /// - public partial class AddPasswordAndEmailVersions : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "email_version", - table: "users", - type: "integer", - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "password_version", - table: "users", - type: "integer", - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "password_version_at_create", - table: "user_password_resets", - type: "integer", - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "email_version_at_create", - table: "user_email_changes", - type: "integer", - nullable: false, - defaultValue: 0); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "email_version", - table: "users"); - - migrationBuilder.DropColumn( - name: "password_version", - table: "users"); - - migrationBuilder.DropColumn( - name: "password_version_at_create", - table: "user_password_resets"); - - migrationBuilder.DropColumn( - name: "email_version_at_create", - table: "user_email_changes"); - } - } -} diff --git a/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.Designer.cs b/Common/Migrations/20260521205924_AddUserSecurityStamp.Designer.cs similarity index 98% rename from Common/Migrations/20260520153340_AddPasswordAndEmailVersions.Designer.cs rename to Common/Migrations/20260521205924_AddUserSecurityStamp.Designer.cs index d92ec70b..08d179d9 100644 --- a/Common/Migrations/20260520153340_AddPasswordAndEmailVersions.Designer.cs +++ b/Common/Migrations/20260521205924_AddUserSecurityStamp.Designer.cs @@ -15,8 +15,8 @@ namespace OpenShock.Common.Migrations { [DbContext(typeof(MigrationOpenShockContext))] - [Migration("20260520153340_AddPasswordAndEmailVersions")] - partial class AddPasswordAndEmailVersions + [Migration("20260521205924_AddUserSecurityStamp")] + partial class AddUserSecurityStamp { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -693,12 +693,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(320)") .HasColumnName("email"); - b.Property("EmailVersion") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("email_version"); - b.Property("Name") .IsRequired() .HasMaxLength(32) @@ -712,17 +706,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("password_hash") .UseCollation("C"); - b.Property("PasswordVersion") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("password_version"); - b.PrimitiveCollection>("Roles") .IsRequired() .HasColumnType("role_type[]") .HasColumnName("roles"); + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + b.HasKey("Id") .HasName("users_pkey"); @@ -813,12 +807,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("created_at") .HasDefaultValueSql("CURRENT_TIMESTAMP"); - b.Property("EmailVersionAtCreate") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("email_version_at_create"); - b.Property("NewEmail") .IsRequired() .HasMaxLength(320) @@ -831,6 +819,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(320)") .HasColumnName("email_old"); + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(128) @@ -974,11 +966,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("created_at") .HasDefaultValueSql("CURRENT_TIMESTAMP"); - b.Property("PasswordVersionAtCreate") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("password_version_at_create"); + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); b.Property("TokenHash") .IsRequired() diff --git a/Common/Migrations/20260521205924_AddUserSecurityStamp.cs b/Common/Migrations/20260521205924_AddUserSecurityStamp.cs new file mode 100644 index 00000000..c5fdbe6e --- /dev/null +++ b/Common/Migrations/20260521205924_AddUserSecurityStamp.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class AddUserSecurityStamp : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "security_stamp", + table: "users", + type: "uuid", + nullable: false, + defaultValueSql: "gen_random_uuid()"); + + migrationBuilder.AddColumn( + name: "security_stamp_at_create", + table: "user_password_resets", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "security_stamp_at_create", + table: "user_email_changes", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "security_stamp", + table: "users"); + + migrationBuilder.DropColumn( + name: "security_stamp_at_create", + table: "user_password_resets"); + + migrationBuilder.DropColumn( + name: "security_stamp_at_create", + table: "user_email_changes"); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index a6653c57..0a030050 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -690,12 +690,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(320)") .HasColumnName("email"); - b.Property("EmailVersion") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("email_version"); - b.Property("Name") .IsRequired() .HasMaxLength(32) @@ -709,17 +703,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("password_hash") .UseCollation("C"); - b.Property("PasswordVersion") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("password_version"); - b.PrimitiveCollection>("Roles") .IsRequired() .HasColumnType("role_type[]") .HasColumnName("roles"); + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + b.HasKey("Id") .HasName("users_pkey"); @@ -810,12 +804,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("created_at") .HasDefaultValueSql("CURRENT_TIMESTAMP"); - b.Property("EmailVersionAtCreate") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("email_version_at_create"); - b.Property("NewEmail") .IsRequired() .HasMaxLength(320) @@ -828,6 +816,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(320)") .HasColumnName("email_old"); + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(128) @@ -971,11 +963,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("created_at") .HasDefaultValueSql("CURRENT_TIMESTAMP"); - b.Property("PasswordVersionAtCreate") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("password_version_at_create"); + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); b.Property("TokenHash") .IsRequired() diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index b8ebce0b..2742d711 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -303,9 +303,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .UseCollation("C") .VarCharWithLength(HardLimits.PasswordResetSecretMaxLength) .HasColumnName("token_hash"); - entity.Property(e => e.PasswordVersionAtCreate) - .HasDefaultValue(0) - .HasColumnName("password_version_at_create"); + entity.Property(e => e.SecurityStampAtCreate) + .HasColumnName("security_stamp_at_create"); entity.Property(e => e.UsedAt) .HasColumnName("used_at"); entity.Property(e => e.UserId).HasColumnName("user_id"); @@ -609,12 +608,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .UseCollation("C") .VarCharWithLength(HardLimits.PasswordHashMaxLength) .HasColumnName("password_hash"); - entity.Property(e => e.PasswordVersion) - .HasDefaultValue(0) - .HasColumnName("password_version"); - entity.Property(e => e.EmailVersion) - .HasDefaultValue(0) - .HasColumnName("email_version"); + entity.Property(e => e.SecurityStamp) + .HasDefaultValueSql("gen_random_uuid()") + .HasColumnName("security_stamp"); entity.Property(e => e.Roles) .HasColumnType("role_type[]") .HasColumnName("roles"); @@ -728,9 +724,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .UseCollation("C") .VarCharWithLength(HardLimits.UserEmailChangeSecretMaxLength) .HasColumnName("token_hash"); - entity.Property(e => e.EmailVersionAtCreate) - .HasDefaultValue(0) - .HasColumnName("email_version_at_create"); + entity.Property(e => e.SecurityStampAtCreate) + .HasColumnName("security_stamp_at_create"); entity.Property(e => e.UsedAt) .HasColumnName("used_at"); entity.Property(e => e.CreatedAt) diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index 76d8bf18..1fcdda1c 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -13,18 +13,12 @@ public sealed class User public string? PasswordHash { get; set; } /// - /// Monotonically increasing version bumped whenever is changed. - /// Snapshotted onto outstanding password reset requests so that any pending reset becomes - /// unusable as soon as the password changes through any path. + /// Opaque value rotated whenever a security-sensitive field on this user changes + /// (currently and ). Snapshotted onto pending + /// password resets and email changes so that any rotation silently invalidates every + /// outstanding request for this user, regardless of which code path applied the change. /// - public int PasswordVersion { get; set; } - - /// - /// Monotonically increasing version bumped whenever is changed. - /// Snapshotted onto outstanding email change requests so that any pending change becomes - /// unusable as soon as the email changes through any path. - /// - public int EmailVersion { get; set; } + public Guid SecurityStamp { get; set; } public List Roles { get; set; } = []; diff --git a/Common/OpenShockDb/UserEmailChange.cs b/Common/OpenShockDb/UserEmailChange.cs index 143db4d6..35ca679d 100644 --- a/Common/OpenShockDb/UserEmailChange.cs +++ b/Common/OpenShockDb/UserEmailChange.cs @@ -37,11 +37,11 @@ public sealed class UserEmailChange public required string TokenHash { get; set; } /// - /// Snapshot of at the moment this request was created. - /// The change is only valid while this still matches the user's current email version, so any - /// other email change going through first silently invalidates this one. + /// Snapshot of at the moment this request was created. + /// The change is only valid while this still matches the user's current stamp, so any + /// password or other email change going through first silently invalidates this one. /// - public required int EmailVersionAtCreate { get; set; } + public required Guid SecurityStampAtCreate { get; set; } /// /// When the verification link was followed and the email was actually changed. Null while the diff --git a/Common/OpenShockDb/UserPasswordReset.cs b/Common/OpenShockDb/UserPasswordReset.cs index 52387139..b2b07f6e 100644 --- a/Common/OpenShockDb/UserPasswordReset.cs +++ b/Common/OpenShockDb/UserPasswordReset.cs @@ -24,11 +24,12 @@ public sealed class UserPasswordReset public required string TokenHash { get; set; } /// - /// Snapshot of at the moment this request was created. - /// The reset is only valid while this still matches the user's current password version, so - /// any password change through any path silently invalidates every outstanding reset link. + /// Snapshot of at the moment this request was created. + /// The reset is only valid while this still matches the user's current stamp, so any + /// password (or email) change through any path silently invalidates every outstanding + /// reset link. /// - public required int PasswordVersionAtCreate { get; set; } + public required Guid SecurityStampAtCreate { get; set; } /// /// When the reset was consumed. Null while the request is still pending. From c16d9a5e8f47e564180981d3618e23237aeb7177 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 21 May 2026 23:10:06 +0200 Subject: [PATCH 09/15] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Cron/Jobs/ClearOldEmailChangesJob.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cron/Jobs/ClearOldEmailChangesJob.cs b/Cron/Jobs/ClearOldEmailChangesJob.cs index 99d867ba..fe102843 100644 --- a/Cron/Jobs/ClearOldEmailChangesJob.cs +++ b/Cron/Jobs/ClearOldEmailChangesJob.cs @@ -6,7 +6,7 @@ namespace OpenShock.Cron.Jobs; /// -/// Deletes old email change requests once they have expired their lifetime and haven't been used. +/// Deletes unused email change requests after they have been expired for an additional audit-retention period. /// [CronJob("0 0 * * *")] // Every day at midnight (https://crontab.guru/) public sealed class ClearOldEmailChangesJob From 653f9bbd8baf3832bba9b77bbdb850f4d4b44f06 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 21 May 2026 23:17:00 +0200 Subject: [PATCH 10/15] guard email/password change against unactivated accounts Adds AccountNotActivated to the OneOf return of CreateEmailChangeFlowAsync and ChangePasswordAsync, so the service refuses to act on a user whose ActivatedAt is null. Both controllers map it to UnreachableException since the only callers are session-authenticated and login already blocks unactivated accounts; the guard is for paths that bypass the controller. --- API/Controller/Account/Authenticated/ChangeEmail.cs | 1 + API/Controller/Account/Authenticated/ChangePassword.cs | 1 + API/Services/Account/AccountService.cs | 6 ++++-- API/Services/Account/IAccountService.cs | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index 12d0dda4..89289a78 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -36,6 +36,7 @@ public async Task ChangeEmail([FromBody] ChangeEmailRequest body) alreadyInUse => Problem(AccountError.EmailChangeAlreadyInUse), unchanged => Problem(AccountError.EmailChangeUnchanged), tooMany => Problem(AccountError.EmailChangeTooMany), + notActivated => throw new UnreachableException("Authenticated user is not activated"), deactivated => Problem(AccountError.AccountDeactivated), notFound => throw new UnreachableException("Authenticated user not found in database")); } diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index 6ea249f8..bb0030fb 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -32,6 +32,7 @@ public async Task ChangePassword([FromBody] ChangePasswordRequest return result.Match( success => Ok(), + notActivated => throw new UnreachableException("Authenticated user is not activated"), deactivated => Problem(AccountError.AccountDeactivated), notFound => throw new UnreachableException("Authenticated user not found in database")); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 3126eedb..9d57851a 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -514,10 +514,11 @@ public async Task - public async Task> ChangePasswordAsync(Guid userId, string newPassword) + public async Task> ChangePasswordAsync(Guid userId, string newPassword) { var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(x => x.Id == userId); if (user is null) return new NotFound(); + if (user.ActivatedAt is null) return new AccountNotActivated(); if (user.UserDeactivation is not null) return new AccountDeactivated(); user.PasswordHash = HashingUtils.HashPassword(newPassword); @@ -529,7 +530,7 @@ public async Task> ChangePasswordAs } /// - public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail) + public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail) { var lowerCaseEmail = newEmail.ToLowerInvariant(); var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; @@ -544,6 +545,7 @@ public async Task= 3) return new TooManyEmailChanges(); diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index a7f0a42d..4cbd2596 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -114,7 +114,7 @@ public interface IAccountService /// /// /// - public Task> ChangePasswordAsync(Guid userId, string newPassword); + public Task> ChangePasswordAsync(Guid userId, string newPassword); /// /// Creates a new email change request and sends a verification email to the new address. @@ -123,7 +123,7 @@ public interface IAccountService /// Id of the user whose email is being changed. /// Requested new email address. /// - public Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail); + public Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail); /// /// Verifies a pending email change using the supplied token. On success the user's email is updated. From dd046db979302fd552c3b9621e58f030943f37d9 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 21 May 2026 23:55:52 +0200 Subject: [PATCH 11/15] delete old requests in migration --- Common/Migrations/20260521205924_AddUserSecurityStamp.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Common/Migrations/20260521205924_AddUserSecurityStamp.cs b/Common/Migrations/20260521205924_AddUserSecurityStamp.cs index c5fdbe6e..38df1298 100644 --- a/Common/Migrations/20260521205924_AddUserSecurityStamp.cs +++ b/Common/Migrations/20260521205924_AddUserSecurityStamp.cs @@ -11,6 +11,13 @@ public partial class AddUserSecurityStamp : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { + // Any existing pending password-reset and email-change rows predate the security stamp, + // so they have no valid snapshot to compare against once the column is added. Clear them + // so affected users have to start a new flow; in-flight email links from before this + // migration are dropped on the floor. + migrationBuilder.Sql("DELETE FROM user_password_resets;"); + migrationBuilder.Sql("DELETE FROM user_email_changes;"); + migrationBuilder.AddColumn( name: "security_stamp", table: "users", From 7ea0850a0507bb9c658cf13e97023a7cd4d485e2 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 22 May 2026 00:00:49 +0200 Subject: [PATCH 12/15] address pr review comments - fix Include() being dropped under Select() projection in CreatePasswordResetFlowAsync and CreateEmailChangeFlowAsync; deactivation was never detected. Project IsDeactivated explicitly. - race-safe complete/verify via ExecuteUpdateAsync gated on the SecurityStamp predicate so two concurrent valid tokens cannot both win and clobber the user's password/email. - add deprecated aliases POST /reset-password and POST /verify-email delegating to the renamed routes. - ChangeEmail: return AccountOAuthOnly (401) for users without a password, instead of the misleading PasswordChangeInvalidPassword. - fix unresolved cref to OpenShock.Common.Constants.Duration in UserPasswordReset and UserEmailChange xmldocs. - clarify User.SecurityStamp doc to make explicit that re-hashing the same password is not a rotation. - update stale EmailVersion comment in MailTests. --- API.IntegrationTests/Tests/MailTests.cs | 2 +- .../Account/Authenticated/ChangeEmail.cs | 8 +- .../Account/PasswordResetInitiateV2.cs | 26 +++++- API/Controller/Account/VerifyEmail.cs | 23 ++++- API/Services/Account/AccountService.cs | 83 +++++++++++++------ Common/OpenShockDb/User.cs | 9 +- Common/OpenShockDb/UserEmailChange.cs | 6 +- Common/OpenShockDb/UserPasswordReset.cs | 6 +- 8 files changed, 123 insertions(+), 40 deletions(-) diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index e2482a9b..24878f81 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -439,7 +439,7 @@ public async Task ChangeEmailFlow_SecondPendingRequest_InvalidatedAfterFirstComp var firstVerify = await anonClient.PostAsync($"/1/account/email-change/verify?token={firstToken}", null); await Assert.That(firstVerify.StatusCode).IsEqualTo(HttpStatusCode.OK); - // Second pending request is now invalid: its EmailVersionAtCreate snapshot no longer matches User.EmailVersion. + // Second pending request is now invalid: its SecurityStampAtCreate snapshot no longer matches User.SecurityStamp. var secondVerify = await anonClient.PostAsync($"/1/account/email-change/verify?token={secondToken}", null); await Assert.That(secondVerify.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index 89289a78..13ae7290 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -19,12 +19,18 @@ public sealed partial class AuthenticatedAccountController [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // EmailChangeUnchanged + [ProducesResponseType(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // AccountOAuthOnly [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse [ProducesResponseType(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany public async Task ChangeEmail([FromBody] ChangeEmailRequest body) { - if (string.IsNullOrEmpty(CurrentUser.PasswordHash) || !HashingUtils.VerifyPassword(body.CurrentPassword, CurrentUser.PasswordHash).Verified) + if (string.IsNullOrEmpty(CurrentUser.PasswordHash)) + { + return Problem(AccountError.AccountOAuthOnly); + } + + if (!HashingUtils.VerifyPassword(body.CurrentPassword, CurrentUser.PasswordHash).Verified) { return Problem(AccountError.PasswordChangeInvalidPassword); } diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index f43b052b..136e0635 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -1,4 +1,5 @@ -using System.Net; +using System; +using System.Net; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; @@ -23,7 +24,24 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("2")] - public async Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) + public Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) + => PasswordResetInitiate(body, turnstileService, cancellationToken); + + /// + /// Initiate a password reset. Deprecated: use POST /password-reset instead. + /// + /// Password reset email sent if the email is associated to an registered account + [Obsolete("Use POST /password-reset instead.")] + [HttpPost("reset-password")] + [EnableRateLimiting("auth")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] + [MapToApiVersion("2")] + public Task PasswordResetInitiateV2Legacy([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) + => PasswordResetInitiate(body, turnstileService, cancellationToken); + + private async Task PasswordResetInitiate(PasswordResetRequestV2 body, ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) { var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken); if (!turnStile.IsT0) @@ -34,9 +52,9 @@ public async Task PasswordResetInitiateV2([FromBody] PasswordRese return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } - + await _accountService.CreatePasswordResetFlowAsync(body.Email); - + return Ok(); } } \ No newline at end of file diff --git a/API/Controller/Account/VerifyEmail.cs b/API/Controller/Account/VerifyEmail.cs index 2e6c2103..93b840d0 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -1,4 +1,5 @@ -using System.Net.Mime; +using System; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; using OpenShock.Common.Errors; @@ -19,7 +20,25 @@ public sealed partial class AccountController [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] [MapToApiVersion("1")] - public async Task EmailVerify([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) + public Task EmailVerify([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) + => VerifyPendingEmailChange(token, cancellationToken); + + /// + /// Verify a pending email change. Deprecated: use POST /email-change/verify instead. + /// + /// Email change verified and applied + /// Token is invalid, already used, or the request has expired + /// The new email address was claimed by another account before verification completed + [Obsolete("Use POST /email-change/verify instead.")] + [HttpPost("verify-email")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] + [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] + [MapToApiVersion("1")] + public Task EmailVerifyLegacy([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) + => VerifyPendingEmailChange(token, cancellationToken); + + private async Task VerifyPendingEmailChange(string token, CancellationToken cancellationToken) { var result = await _accountService.TryVerifyEmailAsync(token, cancellationToken); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 9d57851a..c11dfa7d 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -400,16 +400,16 @@ public async Task x.Email == lowerCaseEmail) - .Include(x => x.UserDeactivation) .Select(x => new { User = x, + IsDeactivated = x.UserDeactivation != null, PasswordResetCount = x.PasswordResets.Count(y => y.UsedAt == null && y.CreatedAt >= validSince) }) .FirstOrDefaultAsync(); if (user is null) return new NotFound(); if (user.User.ActivatedAt is null) return new AccountNotActivated(); - if (user.User.UserDeactivation is not null) return new AccountDeactivated(); + if (user.IsDeactivated) return new AccountDeactivated(); if (user.PasswordResetCount >= 3) return new TooManyPasswordResets(); var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength); @@ -436,21 +436,39 @@ public async Task x.User) - .Include(x => x.User.UserDeactivation) - .FirstOrDefaultAsync(x => x.Id == passwordResetId && x.UsedAt == null && x.CreatedAt >= validSince - && x.SecurityStampAtCreate == x.User.SecurityStamp); + .Select(x => new + { + Reset = x, + UserActivatedAt = x.User.ActivatedAt, + IsDeactivated = x.User.UserDeactivation != null, + UserSecurityStamp = x.User.SecurityStamp + }) + .FirstOrDefaultAsync(x => x.Reset.Id == passwordResetId && x.Reset.UsedAt == null && x.Reset.CreatedAt >= validSince + && x.Reset.SecurityStampAtCreate == x.UserSecurityStamp); if (reset is null) return new NotFound(); - if (reset.User.ActivatedAt is null) return new AccountNotActivated(); - if (reset.User.UserDeactivation is not null) return new AccountDeactivated(); + if (reset.UserActivatedAt is null) return new AccountNotActivated(); + if (reset.IsDeactivated) return new AccountDeactivated(); - var result = HashingUtils.VerifyToken(secret, reset.TokenHash); + var result = HashingUtils.VerifyToken(secret, reset.Reset.TokenHash); if (!result.Verified) return new SecretInvalid(); - reset.UsedAt = DateTime.UtcNow; - reset.User.PasswordHash = HashingUtils.HashPassword(newPassword); - reset.User.SecurityStamp = Guid.CreateVersion7(); // Rotates the stamp; every other pending reset/email-change for this user is now invalid by predicate. - await _db.SaveChangesAsync(); + // Race-safe consume + apply: only updates if SecurityStamp still matches the snapshot. + // If a sibling reset (or a separate password/email change) completed since the read above, + // the stamp has rotated and the predicate matches zero rows. + var newPasswordHash = HashingUtils.HashPassword(newPassword); + var newStamp = Guid.CreateVersion7(); + var userRows = await _db.Users + .Where(u => u.Id == reset.Reset.UserId && u.SecurityStamp == reset.Reset.SecurityStampAtCreate) + .ExecuteUpdateAsync(s => s + .SetProperty(u => u.PasswordHash, newPasswordHash) + .SetProperty(u => u.SecurityStamp, newStamp)); + if (userRows == 0) return new NotFound(); + + var now = DateTime.UtcNow; + await _db.UserPasswordResets + .Where(r => r.Id == reset.Reset.Id && r.UsedAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.UsedAt, now)); + return new Success(); } @@ -537,16 +555,16 @@ public async Task x.Id == userId) - .Include(x => x.UserDeactivation) .Select(x => new { User = x, + IsDeactivated = x.UserDeactivation != null, PendingCount = x.EmailChanges.Count(y => y.UsedAt == null && y.CreatedAt >= validSince) }) .FirstOrDefaultAsync(); if (data is null) return new NotFound(); if (data.User.ActivatedAt is null) return new AccountNotActivated(); - if (data.User.UserDeactivation is not null) return new AccountDeactivated(); + if (data.IsDeactivated) return new AccountDeactivated(); if (string.Equals(data.User.Email, lowerCaseEmail, StringComparison.Ordinal)) return new EmailUnchanged(); if (data.PendingCount >= 3) return new TooManyEmailChanges(); @@ -594,21 +612,31 @@ public async Task> TryVerifyEmailAsy var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; var change = await _db.UserEmailChanges - .Include(x => x.User).ThenInclude(u => u.UserDeactivation) - .FirstOrDefaultAsync(x => x.TokenHash == hash && x.UsedAt == null && x.CreatedAt >= validSince + .Where(x => x.TokenHash == hash && x.UsedAt == null && x.CreatedAt >= validSince && x.SecurityStampAtCreate == x.User.SecurityStamp - && x.User.UserDeactivation == null && x.User.ActivatedAt != null, cancellationToken); + && x.User.UserDeactivation == null && x.User.ActivatedAt != null) + .Select(x => new + { + ChangeId = x.Id, + UserId = x.UserId, + x.NewEmail, + x.SecurityStampAtCreate + }) + .FirstOrDefaultAsync(cancellationToken); if (change is null) return new NotFound(); - change.UsedAt = DateTime.UtcNow; - change.User.Email = change.NewEmail; - change.User.SecurityStamp = Guid.CreateVersion7(); // Rotates the stamp; every other pending reset/email-change for this user is now invalid by predicate. - + // Race-safe consume + apply: only updates if SecurityStamp still matches the snapshot, so + // sibling email changes / password resets that completed since the read above cleanly lose. + var newStamp = Guid.CreateVersion7(); try { - await _db.SaveChangesAsync(cancellationToken); - return new Success(); + var userRows = await _db.Users + .Where(u => u.Id == change.UserId && u.SecurityStamp == change.SecurityStampAtCreate) + .ExecuteUpdateAsync(s => s + .SetProperty(u => u.Email, change.NewEmail) + .SetProperty(u => u.SecurityStamp, newStamp), cancellationToken); + if (userRows == 0) return new NotFound(); } catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) { @@ -616,6 +644,13 @@ public async Task> TryVerifyEmailAsy // The pending row stays as-is (not marked used) so it can expire naturally. return new EmailAlreadyInUse(); } + + var now = DateTime.UtcNow; + await _db.UserEmailChanges + .Where(c => c.Id == change.ChangeId && c.UsedAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(c => c.UsedAt, now), cancellationToken); + + return new Success(); } private async Task CheckPassword(string password, User user) diff --git a/Common/OpenShockDb/User.cs b/Common/OpenShockDb/User.cs index 1fcdda1c..bf5a2990 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -13,10 +13,11 @@ public sealed class User public string? PasswordHash { get; set; } /// - /// Opaque value rotated whenever a security-sensitive field on this user changes - /// (currently and ). Snapshotted onto pending - /// password resets and email changes so that any rotation silently invalidates every - /// outstanding request for this user, regardless of which code path applied the change. + /// Opaque value rotated whenever the user's password or email value changes + /// (re-hashing the same password with a stronger algorithm does not rotate it). + /// Snapshotted onto pending password resets and email changes so that any rotation + /// silently invalidates every outstanding request for this user, regardless of which + /// code path applied the change. /// public Guid SecurityStamp { get; set; } diff --git a/Common/OpenShockDb/UserEmailChange.cs b/Common/OpenShockDb/UserEmailChange.cs index 35ca679d..9b1ecdb5 100644 --- a/Common/OpenShockDb/UserEmailChange.cs +++ b/Common/OpenShockDb/UserEmailChange.cs @@ -1,4 +1,6 @@ -namespace OpenShock.Common.OpenShockDb; +using OpenShock.Common.Constants; + +namespace OpenShock.Common.OpenShockDb; /// /// A pending or completed email-change request. The row is created when an authenticated user @@ -50,7 +52,7 @@ public sealed class UserEmailChange public DateTime? UsedAt { get; set; } /// - /// When the request was created. Combined with + /// When the request was created. Combined with /// to enforce the link's expiry. /// public DateTime CreatedAt { get; set; } diff --git a/Common/OpenShockDb/UserPasswordReset.cs b/Common/OpenShockDb/UserPasswordReset.cs index b2b07f6e..7aa444cb 100644 --- a/Common/OpenShockDb/UserPasswordReset.cs +++ b/Common/OpenShockDb/UserPasswordReset.cs @@ -1,4 +1,6 @@ -namespace OpenShock.Common.OpenShockDb; +using OpenShock.Common.Constants; + +namespace OpenShock.Common.OpenShockDb; /// /// A pending or completed password reset request, created when a user (or someone with knowledge @@ -37,7 +39,7 @@ public sealed class UserPasswordReset public DateTime? UsedAt { get; set; } /// - /// When the reset was created. Combined with + /// When the reset was created. Combined with /// to enforce the link's expiry. /// public DateTime CreatedAt { get; set; } From d79a9bc17010f0dbd8204b5a8b40c68a78fb2ce6 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 22 May 2026 22:22:35 +0200 Subject: [PATCH 13/15] address review: pre-commit send, unreachable consistency, test robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Send verification email before committing the email-change row so a mailer failure no longer leaves orphan rows counting against the rate limit - Make deactivated path unreachable in ChangeEmail/ChangePassword to match notActivated/notFound — UserSessionAuthentication blocks all three - EmailUnchanged compare is now OrdinalIgnoreCase (defensive) - Note that the password-rehash path intentionally does not rotate SecurityStamp (credential value is unchanged) - Sibling-reset test no longer depends on Mailpit ordering --- API.IntegrationTests/Tests/MailTests.cs | 43 ++++++++++--------- .../Account/Authenticated/ChangeEmail.cs | 3 +- .../Account/Authenticated/ChangePassword.cs | 6 +-- API/Services/Account/AccountService.cs | 23 ++++++---- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 24878f81..00fbb1d4 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -472,31 +472,34 @@ public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompl var messages = await mailpit.WaitForMessagesAsync(email, minCount: 2); await Assert.That(messages.Count).IsGreaterThanOrEqualTo(2); - // Mailpit returns messages in reverse-chronological order; take the two for our recipient. - var firstFull = await mailpit.GetMessageAsync(messages[1].Id); - var secondFull = await mailpit.GetMessageAsync(messages[0].Id); - var (firstResetId, firstSecret) = ExtractPasswordResetParams(firstFull!.Html); - var (secondResetId, secondSecret) = ExtractPasswordResetParams(secondFull!.Html); - await Assert.That(firstResetId).IsNotNull().And.IsNotEmpty(); - await Assert.That(secondResetId).IsNotNull().And.IsNotEmpty(); - - // Complete the first reset - var firstComplete = await client.PostAsync( - $"/1/account/password-reset/{firstResetId}/{firstSecret}/complete", + // We don't care which of the two emails came from which request — the scenario is just + // "two valid pending resets exist; completing either must invalidate the other". Pick the + // first two distinct reset-id/secret pairs we see and call them A and B. + var fullA = await mailpit.GetMessageAsync(messages[0].Id); + var fullB = await mailpit.GetMessageAsync(messages[1].Id); + var (resetIdA, secretA) = ExtractPasswordResetParams(fullA!.Html); + var (resetIdB, secretB) = ExtractPasswordResetParams(fullB!.Html); + await Assert.That(resetIdA).IsNotNull().And.IsNotEmpty(); + await Assert.That(resetIdB).IsNotNull().And.IsNotEmpty(); + await Assert.That(resetIdA).IsNotEqualTo(resetIdB); + + // Complete reset A + var completeA = await client.PostAsync( + $"/1/account/password-reset/{resetIdA}/{secretA}/complete", TestHelper.JsonContent(new { password = firstNewPassword })); - await Assert.That(firstComplete.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(completeA.StatusCode).IsEqualTo(HttpStatusCode.OK); - // The second (sibling) reset must no longer be usable - var secondCheck = await client.GetAsync( - $"/1/account/password-reset/{secondResetId}/{secondSecret}"); - await Assert.That(secondCheck.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + // Reset B (sibling) must no longer be usable + var checkB = await client.GetAsync( + $"/1/account/password-reset/{resetIdB}/{secretB}"); + await Assert.That(checkB.StatusCode).IsEqualTo(HttpStatusCode.NotFound); - var secondComplete = await client.PostAsync( - $"/1/account/password-reset/{secondResetId}/{secondSecret}/complete", + var completeB = await client.PostAsync( + $"/1/account/password-reset/{resetIdB}/{secretB}/complete", TestHelper.JsonContent(new { password = secondNewPassword })); - await Assert.That(secondComplete.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + await Assert.That(completeB.StatusCode).IsEqualTo(HttpStatusCode.NotFound); - // First (new) password still works + // Password from the winning reset works var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new { email, diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index 13ae7290..b2e480f6 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -23,6 +23,7 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse [ProducesResponseType(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany + // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. public async Task ChangeEmail([FromBody] ChangeEmailRequest body) { if (string.IsNullOrEmpty(CurrentUser.PasswordHash)) @@ -43,7 +44,7 @@ public async Task ChangeEmail([FromBody] ChangeEmailRequest body) unchanged => Problem(AccountError.EmailChangeUnchanged), tooMany => Problem(AccountError.EmailChangeTooMany), notActivated => throw new UnreachableException("Authenticated user is not activated"), - deactivated => Problem(AccountError.AccountDeactivated), + deactivated => throw new UnreachableException("Authenticated user is deactivated"), notFound => throw new UnreachableException("Authenticated user not found in database")); } } diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index bb0030fb..add24d24 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -19,8 +19,8 @@ public sealed partial class AuthenticatedAccountController [HttpPost("password")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] - [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] + [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword + // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. public async Task ChangePassword([FromBody] ChangePasswordRequest body) { if (!string.IsNullOrEmpty(CurrentUser.PasswordHash) && !HashingUtils.VerifyPassword(body.CurrentPassword, CurrentUser.PasswordHash).Verified) @@ -33,7 +33,7 @@ public async Task ChangePassword([FromBody] ChangePasswordRequest return result.Match( success => Ok(), notActivated => throw new UnreachableException("Authenticated user is not activated"), - deactivated => Problem(AccountError.AccountDeactivated), + deactivated => throw new UnreachableException("Authenticated user is deactivated"), notFound => throw new UnreachableException("Authenticated user not found in database")); } diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index c11dfa7d..dd17b919 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -550,7 +550,6 @@ public async Task public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail) { - var lowerCaseEmail = newEmail.ToLowerInvariant(); var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; var data = await _db.Users @@ -565,12 +564,13 @@ public async Task= 3) return new TooManyEmailChanges(); - if (await IsEmailProviderBlacklisted(lowerCaseEmail)) + if (await IsEmailProviderBlacklisted(newEmail)) return new EmailAlreadyInUse(); // Don't reveal blacklist hits + var lowerCaseEmail = newEmail.ToLowerInvariant(); if (await _db.Users.AnyAsync(x => x.Email == lowerCaseEmail)) return new EmailAlreadyInUse(); @@ -584,16 +584,20 @@ public async Task CheckPassword(string password, User user) if (result.NeedsRehash) { + // Re-hashing the same password to upgrade algorithms intentionally does not rotate + // SecurityStamp, the credential value is unchanged, so outstanding reset / email-change + // links for this user must continue to work. _logger.LogInformation("Rehashing password for user ID: [{Id}]", user.Id); user.PasswordHash = HashingUtils.HashPassword(password); await _db.SaveChangesAsync(); From 86be8e28af1a768d845fcbf38e2539ddfcccee13 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 23 May 2026 23:20:48 +0200 Subject: [PATCH 14/15] Disable password change endpoint for OAuth-only accounts --- API/Controller/Account/Authenticated/ChangePassword.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index add24d24..a42e772f 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -20,10 +20,18 @@ public sealed partial class AuthenticatedAccountController [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword + [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // PasswordNotSet // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. public async Task ChangePassword([FromBody] ChangePasswordRequest body) { - if (!string.IsNullOrEmpty(CurrentUser.PasswordHash) && !HashingUtils.VerifyPassword(body.CurrentPassword, CurrentUser.PasswordHash).Verified) + // OAuth-only accounts that have never set a password must go through the email-confirmed + // /password/set flow rather than silently setting one through this endpoint. + if (string.IsNullOrEmpty(CurrentUser.PasswordHash)) + { + return Problem(AccountError.PasswordNotSet); + } + + if (!HashingUtils.VerifyPassword(body.CurrentPassword, CurrentUser.PasswordHash).Verified) { return Problem(AccountError.PasswordChangeInvalidPassword); } From e02ebcc21e81f821809200777c87d35988ec05e8 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 23 May 2026 23:27:43 +0200 Subject: [PATCH 15/15] Update AccountError.cs --- Common/Errors/AccountError.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Common/Errors/AccountError.cs b/Common/Errors/AccountError.cs index abd9576f..0f76aab9 100644 --- a/Common/Errors/AccountError.cs +++ b/Common/Errors/AccountError.cs @@ -31,6 +31,9 @@ public static class AccountError public static OpenShockProblem AccountOAuthOnly => new OpenShockProblem("Account.OAuthOnly", "This account is only accessible via OAuth", HttpStatusCode.Unauthorized); + public static OpenShockProblem PasswordNotSet => new OpenShockProblem("Account.Password.NotSet", + "This account has no password set. Initiate a password set flow via POST /password/set instead.", HttpStatusCode.Conflict); + public static OpenShockProblem EmailChangeAlreadyInUse => new OpenShockProblem("Account.Email.AlreadyInUse", "This email address is already in use", HttpStatusCode.Conflict);