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..71947eb2 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.CreateVersion7().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.CreateVersion7().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 544f2c99..00fbb1d4 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,13 +107,14 @@ 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 + var response = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" @@ -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(); @@ -153,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); @@ -172,6 +174,362 @@ public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword() await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); } + // --- Change Email --- + + [Test] + public async Task ChangeEmailFlow_ViaEmailLink_ChangesEmail() + { + 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, username, oldEmail, password); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Initiate the email change + var initiateResponse = await client.PostAsync("/1/account/email-change", 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/email-change/verify?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/email-change/verify?token={token}", null); + await Assert.That(replayResponse.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task ChangeEmail_WrongPassword_Returns403_AndSendsNoEmail() + { + 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, username, oldEmail, "CorrectPassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new + { + currentPassword = "WrongPassword!", + email = newEmail + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + + // 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 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_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() + { + 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.CreateVersion7(); + const string bogusSecret = "thisisnotarealtokenatallzz"; + + var response = await client.GetAsync($"/1/account/password-reset/{bogusId}/{bogusSecret}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task ChangeEmailFlow_SecondPendingRequest_InvalidatedAfterFirstCompletes() + { + 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, username, oldEmail, password); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Initiate two concurrent email change requests + 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-change", 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/email-change/verify?token={firstToken}", null); + await Assert.That(firstVerify.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // 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); + + 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() + { + 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, username, email, "OldPassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + + // 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); + + var secondInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + await Assert.That(secondInit.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var messages = await mailpit.WaitForMessagesAsync(email, minCount: 2); + await Assert.That(messages.Count).IsGreaterThanOrEqualTo(2); + + // 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(completeA.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // 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 completeB = await client.PostAsync( + $"/1/account/password-reset/{resetIdB}/{secretB}/complete", + TestHelper.JsonContent(new { password = secondNewPassword })); + await Assert.That(completeB.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + + // Password from the winning reset 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() + { + 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, 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-change", TestHelper.JsonContent(new + { + currentPassword = password, + email = takenEmail + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + // --- Helpers --- /// 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/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index de552993..b2e480f6 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -1,23 +1,50 @@ -using System.Net.Mime; +using System.Diagnostics; +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")] + [HttpPost("email-change")] [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.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 + // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. + public async Task ChangeEmail([FromBody] ChangeEmailRequest body) { - throw new NotImplementedException(); + if (string.IsNullOrEmpty(CurrentUser.PasswordHash)) + { + return Problem(AccountError.AccountOAuthOnly); + } + + if (!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), + notActivated => throw new UnreachableException("Authenticated user is not activated"), + deactivated => throw new UnreachableException("Authenticated user is deactivated"), + notFound => throw new UnreachableException("Authenticated user not found in database")); } -} \ No newline at end of file +} diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index ee79e22c..a42e772f 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; @@ -18,11 +19,19 @@ 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 + [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); } @@ -31,8 +40,9 @@ 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...") - ); + notActivated => throw new UnreachableException("Authenticated user is not activated"), + deactivated => throw new UnreachableException("Authenticated user is deactivated"), + notFound => throw new UnreachableException("Authenticated user not found in database")); + } } \ No newline at end of file diff --git a/API/Controller/Account/PasswordResetCheckValid.cs b/API/Controller/Account/PasswordResetCheckValid.cs index d66f869d..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; @@ -18,12 +19,32 @@ public sealed partial class AccountController /// /// Valid password reset process /// Password reset process not found + [HttpGet("password-reset/{passwordResetId}/{secret}")] + [EnableRateLimiting("auth")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound + [MapToApiVersion("1")] + 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 async Task PasswordResetCheckValid([FromRoute] Guid passwordResetId, [FromRoute] string secret, CancellationToken cancellationToken) + 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( diff --git a/API/Controller/Account/PasswordResetComplete.cs b/API/Controller/Account/PasswordResetComplete.cs index d9e58c93..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; @@ -18,14 +19,36 @@ public sealed partial class AccountController /// /// Password successfully changed /// Password reset process not found + [HttpPost("password-reset/{passwordResetId}/{secret}/complete")] + [EnableRateLimiting("auth")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound + [MapToApiVersion("1")] + 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 async Task PasswordResetComplete([FromRoute] Guid passwordResetId, + 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); diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 2c9e1a76..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; @@ -17,13 +18,30 @@ public sealed partial class AccountController /// Initiate a password reset /// /// Password reset email sent if the email is associated to an registered account + [HttpPost("password-reset")] + [EnableRateLimiting("auth")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] + [MapToApiVersion("2")] + 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 async Task PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) + 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 f107bf40..93b840d0 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -1,6 +1,8 @@ -using System.Net.Mime; +using System; +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 +10,41 @@ 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, already used, or the request has expired + /// The new email address was claimed by another account before verification completed + [HttpPost("email-change/verify")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] + [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] + [MapToApiVersion("1")] + 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.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] + [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 EmailVerifyLegacy([FromQuery(Name = "token")] string token, CancellationToken cancellationToken) + => VerifyPendingEmailChange(token, cancellationToken); + + private async Task VerifyPendingEmailChange(string token, CancellationToken cancellationToken) { - bool ok = await _accountService.TryVerifyEmailAsync(token, cancellationToken); - - return Ok(); + var result = await _accountService.TryVerifyEmailAsync(token, cancellationToken); + + return result.Match( + success => Ok(), + notFound => Problem(AccountError.EmailChangeNotFound), + emailTaken => Problem(AccountError.EmailChangeAlreadyInUse)); } } \ 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/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 08c766bf..dd17b919 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.SecurityStampAtCreate == x.User.SecurityStamp, 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(); } @@ -399,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); @@ -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); + .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); - 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(); } @@ -510,31 +532,129 @@ 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); + 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(); + + return new Success(); + } + + /// + public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail) + { + var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; + + var data = await _db.Users + .Where(x => x.Id == userId) + .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.IsDeactivated) return new AccountDeactivated(); + if (string.Equals(data.User.Email, newEmail, StringComparison.OrdinalIgnoreCase)) return new EmailUnchanged(); + if (data.PendingCount >= 3) return new TooManyEmailChanges(); + + 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(); + 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), + SecurityStampAtCreate = data.User.SecurityStamp + }; + + // Dispatch the verification email *before* committing the row. If the mail service throws + // (provider outage, transient network failure), the exception propagates and the row is + // never inserted, the user can simply retry without burning a pending-count slot. + await _emailService.VerifyEmail(new Contact(lowerCaseEmail, data.User.Name), + new Uri(_frontendConfig.BaseUrl, $"/verify-email?token={token}")); + + _db.UserEmailChanges.Add(emailChange); await _db.SaveChangesAsync(); + // Notify the previous address so the legitimate owner sees the change request even if + // the session/password used to start it was compromised. Best-effort: the verification + // email has already been dispatched and the row is committed, so a failure here must not + // unwind the request. + try + { + await _emailService.EmailChangeNotice(new Contact(data.User.Email, data.User.Name), lowerCaseEmail); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send email-change notice to previous address for user {UserId}", data.User.Id); + } + 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; - 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); + var change = await _db.UserEmailChanges + .Where(x => x.TokenHash == hash && x.UsedAt == null && x.CreatedAt >= validSince + && x.SecurityStampAtCreate == x.User.SecurityStamp + && 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(); + + // 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 + { + 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" }) + { + // Another account claimed this email between request creation and verification. + // 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 nChanges > 0; + return new Success(); } private async Task CheckPassword(string password, User user) @@ -554,6 +674,9 @@ private 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(); diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 41b9d445..4cbd2596 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -114,15 +114,27 @@ 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. + /// 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. + /// 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; @@ -138,4 +150,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/IEmailService.cs b/API/Services/Email/IEmailService.cs index 206da8d2..12aee102 100644 --- a/API/Services/Email/IEmailService.cs +++ b/API/Services/Email/IEmailService.cs @@ -30,4 +30,14 @@ public interface IEmailService /// /// 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 d93e9a3d..ee2ab771 100644 --- a/API/Services/Email/Smtp/SmtpEmailService.cs +++ b/API/Services/Email/Smtp/SmtpEmailService.cs @@ -51,7 +51,11 @@ 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); + + /// + 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, 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

+
+ + 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..0f76aab9 100644 --- a/Common/Errors/AccountError.cs +++ b/Common/Errors/AccountError.cs @@ -30,4 +30,19 @@ 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 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); + + 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/20260521205924_AddUserSecurityStamp.Designer.cs b/Common/Migrations/20260521205924_AddUserSecurityStamp.Designer.cs new file mode 100644 index 00000000..08d179d9 --- /dev/null +++ b/Common/Migrations/20260521205924_AddUserSecurityStamp.Designer.cs @@ -0,0 +1,1477 @@ +// +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("20260521205924_AddUserSecurityStamp")] + partial class AddUserSecurityStamp + { + /// + 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("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.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"); + + 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("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("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + 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("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_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/20260521205924_AddUserSecurityStamp.cs b/Common/Migrations/20260521205924_AddUserSecurityStamp.cs new file mode 100644 index 00000000..38df1298 --- /dev/null +++ b/Common/Migrations/20260521205924_AddUserSecurityStamp.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + 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", + 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 194008aa..0a030050 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" }); @@ -708,6 +708,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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,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) @@ -953,6 +963,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("created_at") .HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(100) diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 2ad4b77f..2742d711 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -303,6 +303,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .UseCollation("C") .VarCharWithLength(HardLimits.PasswordResetSecretMaxLength) .HasColumnName("token_hash"); + 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"); @@ -606,6 +608,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .UseCollation("C") .VarCharWithLength(HardLimits.PasswordHashMaxLength) .HasColumnName("password_hash"); + entity.Property(e => e.SecurityStamp) + .HasDefaultValueSql("gen_random_uuid()") + .HasColumnName("security_stamp"); entity.Property(e => e.Roles) .HasColumnType("role_type[]") .HasColumnName("roles"); @@ -719,6 +724,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .UseCollation("C") .VarCharWithLength(HardLimits.UserEmailChangeSecretMaxLength) .HasColumnName("token_hash"); + 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 9594ecfc..bf5a2990 100644 --- a/Common/OpenShockDb/User.cs +++ b/Common/OpenShockDb/User.cs @@ -12,6 +12,15 @@ public sealed class User public string? PasswordHash { get; set; } + /// + /// 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; } + public List Roles { get; set; } = []; public DateTime CreatedAt { get; set; } diff --git a/Common/OpenShockDb/UserEmailChange.cs b/Common/OpenShockDb/UserEmailChange.cs index 04040269..9b1ecdb5 100644 --- a/Common/OpenShockDb/UserEmailChange.cs +++ b/Common/OpenShockDb/UserEmailChange.cs @@ -1,19 +1,60 @@ -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 +/// 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 stamp, so any + /// password or other email change going through first silently invalidates this one. + /// + public required Guid SecurityStampAtCreate { 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..7aa444cb 100644 --- a/Common/OpenShockDb/UserPasswordReset.cs +++ b/Common/OpenShockDb/UserPasswordReset.cs @@ -1,15 +1,47 @@ -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 +/// 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 stamp, so any + /// password (or email) change through any path silently invalidates every outstanding + /// reset link. + /// + public required Guid SecurityStampAtCreate { 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..fe102843 --- /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 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 +{ + 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; + } +}