From 105d509430a491d55cb6b4c8402ecd530669b666 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 24 Jun 2026 09:57:30 +0200 Subject: [PATCH 01/11] feat: resend account activation email endpoint Adds POST /{version}/account/activate/resend so users can request a fresh activation email. The endpoint: - rotates the activation token (invalidating any previously sent link) and persists it before sending, so the emailed link matches the stored hash - creates an activation request if the unactivated account lacks one (e.g. legacy data or a failed initial send) - silently no-ops for unknown, already-activated, or deactivated accounts and always returns a generic 200, so it can't be used to probe account state - is rate limited under the existing "auth" policy and tracks EmailSendAttempts No database migration required (reuses the existing UserActivationRequest table). Co-Authored-By: Claude Opus 4.8 (1M context) --- API.IntegrationTests/Tests/MailTests.cs | 113 +++++++++++++++++++++ API/Controller/Account/ResendActivation.cs | 32 ++++++ API/Services/Account/AccountService.cs | 41 ++++++++ API/Services/Account/IAccountService.cs | 9 ++ 4 files changed, 195 insertions(+) create mode 100644 API/Controller/Account/ResendActivation.cs diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 00fbb1d4..00c9c13d 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -83,6 +83,119 @@ public async Task ActivationFlow_ViaEmailLink_ActivatesAccount() await Assert.That(user!.ActivatedAt).IsNotNull(); } + // --- Resend Activation --- + + [Test] + public async Task ResendActivation_UnactivatedUser_SendsWorkingActivationEmail() + { + var email = TestHelper.UniqueEmail("mail-resend-activate"); + var username = TestHelper.UniqueUsername("mailresendactivate"); + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + // Unactivated user with no existing activation request — exercises the create-request path. + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var message = await mailpit.WaitForMessageAsync(email); + await Assert.That(message).IsNotNull(); + await Assert.That(message!.To?.Select(c => c.Address)).Contains(email); + + // The link in the resent email must actually activate the account. + var fullMessage = await mailpit.GetMessageAsync(message.Id); + var token = ExtractQueryParam(fullMessage!.Html, "token"); + await Assert.That(token).IsNotNull().And.IsNotEmpty(); + + var activateResponse = await client.PostAsync($"/1/account/activate?token={token}", null); + await Assert.That(activateResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.AsNoTracking().FirstAsync(u => u.Email == email); + await Assert.That(user.ActivatedAt).IsNotNull(); + } + + [Test] + public async Task ResendActivation_RotatesToken_PreviousLinkInvalidated() + { + var email = TestHelper.UniqueEmail("mail-resend-rotate"); + var username = TestHelper.UniqueUsername("mailresendrotate"); + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + using var client = WebApplicationFactory.CreateClient(); + + // Sign up (V2) — creates an unactivated user and sends the first activation email. + var signupResponse = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username, + password = "SecurePassword123#", + email, + turnstileResponse = "valid-token" + })); + await Assert.That(signupResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var firstMessage = await mailpit.WaitForMessageAsync(email); + await Assert.That(firstMessage).IsNotNull(); + var firstFull = await mailpit.GetMessageAsync(firstMessage!.Id); + var firstToken = ExtractQueryParam(firstFull!.Html, "token"); + await Assert.That(firstToken).IsNotNull().And.IsNotEmpty(); + + // Resend — rotates the token and sends a second email. + var resendResponse = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + await Assert.That(resendResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var messages = await mailpit.WaitForMessagesAsync(email, minCount: 2); + await Assert.That(messages.Count).IsGreaterThanOrEqualTo(2); + + var secondMessage = messages.First(m => m.Id != firstMessage.Id); + var secondFull = await mailpit.GetMessageAsync(secondMessage.Id); + var secondToken = ExtractQueryParam(secondFull!.Html, "token"); + await Assert.That(secondToken).IsNotNull().And.IsNotEmpty(); + await Assert.That(secondToken).IsNotEqualTo(firstToken); + + // The original (rotated-out) token must no longer activate the account. + var staleActivate = await client.PostAsync($"/1/account/activate?token={firstToken}", null); + await Assert.That(staleActivate.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + + // The freshly issued token works. + var freshActivate = await client.PostAsync($"/1/account/activate?token={secondToken}", null); + await Assert.That(freshActivate.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task ResendActivation_AlreadyActivatedUser_Returns200_AndSendsNoEmail() + { + var email = TestHelper.UniqueEmail("mail-resend-activated"); + var username = TestHelper.UniqueUsername("mailresendactivated"); + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: true); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + + // Generic 200 (no account-state leak), but nothing is sent to an already-activated account. + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var message = await mailpit.WaitForMessageAsync(email, TimeSpan.FromSeconds(2)); + await Assert.That(message).IsNull(); + } + + [Test] + public async Task ResendActivation_UnknownEmail_Returns200_AndSendsNoEmail() + { + var email = TestHelper.UniqueEmail("mail-resend-unknown"); + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var message = await mailpit.WaitForMessageAsync(email, TimeSpan.FromSeconds(2)); + await Assert.That(message).IsNull(); + } + // --- Password Reset --- [Test] diff --git a/API/Controller/Account/ResendActivation.cs b/API/Controller/Account/ResendActivation.cs new file mode 100644 index 00000000..a8e9c02e --- /dev/null +++ b/API/Controller/Account/ResendActivation.cs @@ -0,0 +1,32 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using OpenShock.Common.Models; +using Asp.Versioning; +using Microsoft.AspNetCore.RateLimiting; +using OpenShock.Common.DataAnnotations; + +namespace OpenShock.API.Controller.Account; + +public sealed partial class AccountController +{ + /// + /// Resend the account activation email + /// + /// Activation email sent if the email is associated to an unactivated account + [HttpPost("activate/resend")] + [EnableRateLimiting("auth")] + [Consumes(MediaTypeNames.Application.Json)] + [MapToApiVersion("1")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + public async Task ResendActivation([FromBody] ResendActivationRequest body, CancellationToken cancellationToken) + { + await _accountService.ResendActivationEmailAsync(body.Email, cancellationToken); + return LegacyEmptyOk("Activation email has been sent if the email is associated to an unactivated account"); + } + + public sealed class ResendActivationRequest + { + [EmailAddress(true)] + public required string Email { get; init; } + } +} diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 398df273..1ae0ba7a 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -234,6 +234,47 @@ public async Task TryActivateAccountAsync(string secret, CancellationToken return true; } + /// + public async Task ResendActivationEmailAsync(string email, CancellationToken cancellationToken = default) + { + var lowerCaseEmail = email.ToLowerInvariant(); + + var user = await _db.Users + .Include(u => u.UserDeactivation) + .Include(u => u.UserActivationRequest) + .FirstOrDefaultAsync(u => u.Email == lowerCaseEmail, cancellationToken); + + // Silently no-op when there is nothing to send, so this endpoint can't be used to probe + // which emails are registered, already activated, or deactivated. + if (user is null || user.ActivatedAt is not null || user.UserDeactivation is not null) return; + + // Rotate the activation token (invalidating any previously sent link) and persist before + // sending, so the link in the new email is the one stored in the database. + var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength); + + if (user.UserActivationRequest is null) + { + // Account is pending activation but has no activation request (e.g. legacy data or a + // failed initial send). Create one so the user can still complete activation. + user.UserActivationRequest = new UserActivationRequest + { + UserId = user.Id, + TokenHash = HashingUtils.HashToken(token), + EmailSendAttempts = 1 + }; + } + else + { + user.UserActivationRequest.TokenHash = HashingUtils.HashToken(token); + user.UserActivationRequest.EmailSendAttempts++; + } + + await _db.SaveChangesAsync(cancellationToken); + + await _emailService.ActivateAccount(new Contact(user.Email, user.Name), + new Uri(_frontendConfig.BaseUrl, $"/activate?token={token}"), cancellationToken); + } + /// public async Task> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater) { diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 64c4a1ae..3bd20985 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -55,6 +55,15 @@ public interface IAccountService /// Task TryActivateAccountAsync(string token, CancellationToken cancellationToken = default); + /// + /// Resends the account activation email for an unactivated account, rotating the activation token. + /// Silently does nothing when no email is needed (unknown email, already activated, or deactivated) + /// to avoid leaking account state. + /// + /// The email address of the account to resend the activation email for. + /// + Task ResendActivationEmailAsync(string email, CancellationToken cancellationToken = default); + public Task> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater = true); public Task> ReactivateAccountAsync(Guid executingUserId, Guid userId); From a3b819b1b6e1eae8cb3c99443ad919f2b1b4fdbc Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 14 Aug 2026 12:04:21 +0200 Subject: [PATCH 02/11] refactor: return a plain 200 from the activation resend endpoint Drop the LegacyEmptyResponse envelope in favour of a bare Ok(), matching how the newer endpoints (PasswordResetInitiateV2, SignUpV2) respond. The message it carried only restated the documented 200 response. The integration tests only assert the status code, so they are unaffected. --- API/Controller/Account/ResendActivation.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/API/Controller/Account/ResendActivation.cs b/API/Controller/Account/ResendActivation.cs index a8e9c02e..06353b0a 100644 --- a/API/Controller/Account/ResendActivation.cs +++ b/API/Controller/Account/ResendActivation.cs @@ -1,6 +1,5 @@ using System.Net.Mime; using Microsoft.AspNetCore.Mvc; -using OpenShock.Common.Models; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; using OpenShock.Common.DataAnnotations; @@ -17,11 +16,11 @@ public sealed partial class AccountController [EnableRateLimiting("auth")] [Consumes(MediaTypeNames.Application.Json)] [MapToApiVersion("1")] - [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task ResendActivation([FromBody] ResendActivationRequest body, CancellationToken cancellationToken) { await _accountService.ResendActivationEmailAsync(body.Email, cancellationToken); - return LegacyEmptyOk("Activation email has been sent if the email is associated to an unactivated account"); + return Ok(); } public sealed class ResendActivationRequest From 6b16e0449c0942dfdd8dec922ea5c93151879853 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 30 Sep 2026 16:13:04 +0200 Subject: [PATCH 03/11] fix: require turnstile on the resend activation endpoint The endpoint is unauthenticated and puts mail in an arbitrary third party's inbox, which is exactly the shape of password-reset initiation - but it shipped with only the auth rate limiter, leaving volume as the sole control. Verify turnstile before enqueuing, mirroring PasswordResetInitiateV2, and cover the rejected-token path with a test. --- API.IntegrationTests/Tests/MailTests.cs | 28 ++++++++++++++++++---- API/Controller/Account/ResendActivation.cs | 19 +++++++++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index b679b830..5f1a3964 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -60,7 +60,7 @@ public async Task ResendActivation_UnactivatedUser_EnqueuesActivationOutbox() var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); @@ -95,7 +95,7 @@ public async Task ResendActivation_WithExistingRequest_EnqueuesAnotherActivation // Resend enqueues another activation email. The delivery job re-mints the token and supersedes the // older row at send time (coalesce key) - that token rotation is covered in Cron.IntegrationTests. - var resendResponse = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + var resendResponse = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); await Assert.That(resendResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); @@ -117,7 +117,7 @@ public async Task ResendActivation_AlreadyActivatedUser_Returns200_AndEnqueuesNo await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: true); using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); // Generic 200 (no account-state leak), but nothing is enqueued for an already-activated account. await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); @@ -134,7 +134,7 @@ public async Task ResendActivation_UnknownEmail_Returns200_AndEnqueuesNothing() var email = TestHelper.UniqueEmail("mail-resend-unknown"); using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email })); + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); @@ -144,6 +144,26 @@ public async Task ResendActivation_UnknownEmail_Returns200_AndEnqueuesNothing() await Assert.That(enqueued).IsEqualTo(0); } + [Test] + public async Task ResendActivation_InvalidTurnstile_Returns403_AndEnqueuesNothing() + { + var email = TestHelper.UniqueEmail("mail-resend-turnstile"); + var username = TestHelper.UniqueUsername("mailresendturnstile"); + + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "invalid-token" })); + + // A failed turnstile must stop the send outright - this endpoint mails a third party. + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var enqueued = await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email); + await Assert.That(enqueued).IsEqualTo(0); + } + // --- Password Reset --- [Test] diff --git a/API/Controller/Account/ResendActivation.cs b/API/Controller/Account/ResendActivation.cs index 06353b0a..10ded8c2 100644 --- a/API/Controller/Account/ResendActivation.cs +++ b/API/Controller/Account/ResendActivation.cs @@ -1,8 +1,13 @@ +using System.ComponentModel.DataAnnotations; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Services.Turnstile; +using OpenShock.Common.Constants; using OpenShock.Common.DataAnnotations; +using OpenShock.Common.Problems; +using OpenShock.Internal.Common.Problems; namespace OpenShock.API.Controller.Account; @@ -17,15 +22,25 @@ public sealed partial class AccountController [Consumes(MediaTypeNames.Application.Json)] [MapToApiVersion("1")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task ResendActivation([FromBody] ResendActivationRequest body, CancellationToken cancellationToken) + [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] + public async Task ResendActivation([FromBody] ResendActivationRequest body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) { + // Unauthenticated and it puts mail in a third party's inbox, so it carries the same turnstile + // requirement as password-reset initiation; the rate limiter alone gates nothing but volume. + var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); + if (turnstileError is not null) return turnstileError; + await _accountService.ResendActivationEmailAsync(body.Email, cancellationToken); return Ok(); } public sealed class ResendActivationRequest { - [EmailAddress(true)] + [OpenShock.Common.DataAnnotations.EmailAddress(true)] public required string Email { get; init; } + + [Required(AllowEmptyStrings = false)] + [StringLength(ApiHardLimits.MaxTurnstileResponseTokenLength)] + public required string TurnstileResponse { get; init; } } } From 5e1310ef466d1d048df5e77dd00253dc7bf6d6aa Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 19:01:43 +0100 Subject: [PATCH 04/11] feat: resend activation email on login, rate limit resends - Add a per-user resend limiter (one email per 10 min, max 5 per 24h, signup email included), enforced against the email outbox so it holds across instances. The anonymous /activate/resend endpoint delegates to it. - Password login (v2) with the correct password on an unactivated account now re-sends the activation email before returning AccountNotActivated. - OAuth handoff no longer creates a session for unactivated/deactivated users (session auth would reject it, bouncing them silently to login); it re-sends the activation email and redirects with accountNotActivated. - Map Google's email_verified claim so verified Google signups are activated immediately instead of left pending. --- API.IntegrationTests/Tests/MailTests.cs | 98 +++++++++++++++++++ API/Controller/Account/LoginV2.cs | 9 ++ API/Controller/OAuth/HandOff.cs | 17 ++++ API/Program.cs | 6 +- API/Services/Account/AccountService.cs | 62 ++++++++---- API/Services/Account/IAccountService.cs | 21 +++- .../OAuthConnection/OAuthConnectionService.cs | 2 + Common/Constants/Constants.cs | 3 + 8 files changed, 192 insertions(+), 26 deletions(-) diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 5f1a3964..e0f68084 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -93,6 +93,9 @@ public async Task ResendActivation_WithExistingRequest_EnqueuesAnotherActivation })); await Assert.That(signupResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + // Move the signup email out of the resend cooldown. + await BackdateOutboxAsync(email, TimeSpan.FromMinutes(11)); + // Resend enqueues another activation email. The delivery job re-mints the token and supersedes the // older row at send time (coalesce key) - that token rotation is covered in Cron.IntegrationTests. var resendResponse = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); @@ -108,6 +111,101 @@ public async Task ResendActivation_WithExistingRequest_EnqueuesAnotherActivation await Assert.That(rows.All(r => r.CoalesceKey == EmailOutboxCoalesceKeys.AccountActivation(user.Id))).IsTrue(); } + [Test] + public async Task ResendActivation_WithinCooldown_Returns200_AndEnqueuesNothing() + { + var email = TestHelper.UniqueEmail("mail-resend-cooldown"); + var username = TestHelper.UniqueUsername("mailresendcooldown"); + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + + using var client = WebApplicationFactory.CreateClient(); + for (var i = 0; i < 3; i++) + { + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + await Assert.That(await CountOutboxAsync(email)).IsEqualTo(1); + } + + [Test] + public async Task ResendActivation_DailyCapReached_EnqueuesNothing() + { + var email = TestHelper.UniqueEmail("mail-resend-cap"); + var username = TestHelper.UniqueUsername("mailresendcap"); + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + + using var client = WebApplicationFactory.CreateClient(); + for (var i = 0; i < 6; i++) + { + var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + // Step past the cooldown each time; only the 24h cap of 5 should stop the sixth. + await BackdateOutboxAsync(email, TimeSpan.FromMinutes(11)); + } + + await Assert.That(await CountOutboxAsync(email)).IsEqualTo(5); + } + + [Test] + public async Task Login_UnactivatedUser_CorrectPassword_ReturnsNotActivated_AndEnqueuesActivation() + { + var email = TestHelper.UniqueEmail("mail-login-unactivated"); + var username = TestHelper.UniqueUsername("maillogunact"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = email, + password = "SecurePassword123#", + turnstileResponse = "valid-token" + })); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + await Assert.That(await response.Content.ReadAsStringAsync()).Contains("Account.AccountNotActivated"); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.EmailOutbox.AsNoTracking().SingleAsync(m => m.Recipient == email); + await Assert.That(row.Type).IsEqualTo(EmailType.AccountActivation); + await Assert.That(row.CoalesceKey).IsEqualTo(EmailOutboxCoalesceKeys.AccountActivation(userId)); + } + + [Test] + public async Task Login_UnactivatedUser_WrongPassword_EnqueuesNothing() + { + var email = TestHelper.UniqueEmail("mail-login-unact-wrong"); + var username = TestHelper.UniqueUsername("maillogunactw"); + await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = email, + password = "WrongPassword123#", + turnstileResponse = "valid-token" + })); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + + await Assert.That(await CountOutboxAsync(email)).IsEqualTo(0); + } + + private async Task CountOutboxAsync(string email) + { + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email); + } + + private async Task BackdateOutboxAsync(string email, TimeSpan by) + { + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.EmailOutbox + .Where(m => m.Recipient == email) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.CreatedAt, m => m.CreatedAt - by)); + } + [Test] public async Task ResendActivation_AlreadyActivatedUser_Returns200_AndEnqueuesNothing() { diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 4fbe8bc8..81bae07b 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -52,6 +52,15 @@ public async Task LoginV2( var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken); if (getAccountResult is not User account) { + // AccountNotActivated is only returned after the password checked out, so the caller owns this + // account: (re)send the activation link. Rate limited inside the service, and the response is the + // same whether or not a mail went out. + if (getAccountResult is AccountNotActivated) + { + await _accountService.ResendActivationEmailAsync(body.UsernameOrEmail, cancellationToken); + } + + return getAccountResult switch { Results.NotFound => Problem(LoginError.InvalidCredentials), diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 93e38917..8affc2bc 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -76,6 +76,23 @@ await _accountService.IsEmailRegisteredAsync(externalEmail, cancellationToken)) return RedirectFrontendPath($"/oauth/{Uri.EscapeDataString(provider)}/create"); } + // Session auth rejects (and deletes) sessions of unactivated or deactivated users, so creating one + // here would bounce the user silently back to the login page. Surface the reason instead. + if (connection.User.UserDeactivation is not null) + { + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("accountDeactivated"); + } + + if (connection.User.ActivatedAt is null) + { + // The provider vouched for the identity, so the caller owns this account: (re)send the + // activation link (rate limited inside the service). + await _accountService.ResendActivationEmailAsync(connection.UserId, cancellationToken); + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); + return RedirectFrontendError("accountNotActivated"); + } + // Direct sign-in var domain = GetCurrentCookieDomain(); if (string.IsNullOrEmpty(domain)) diff --git a/API/Program.cs b/API/Program.cs index 32e93dc3..91269108 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -69,7 +69,11 @@ DefaultOptions(options, "google"); options.ClientId = googleOptions.ClientId; options.ClientSecret = googleOptions.ClientSecret; - + + // The default Google claim actions don't map email_verified (OIDC userinfo v3). Without it every + // Google signup is treated as an untrusted email and the account is left unactivated. + options.ClaimActions.MapJsonKey(OAuthConstants.ClaimEmailVerified, "email_verified"); + options.Validate(); }); } diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index bc84e1b5..ec64b6bd 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -281,37 +281,59 @@ public async Task TryActivateAccountAsync(string secret, CancellationToken } /// - public async Task ResendActivationEmailAsync(string email, CancellationToken cancellationToken = default) + public async Task ResendActivationEmailAsync(string usernameOrEmail, CancellationToken cancellationToken = default) { - var lowerCaseEmail = email.ToLowerInvariant(); + var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant(); + + var userId = await _db.Users + .Where(u => u.Email == lowercaseUsernameOrEmail || u.Name == lowercaseUsernameOrEmail) + .Select(u => (Guid?)u.Id) + .FirstOrDefaultAsync(cancellationToken); + + // Silently no-op (and ignore the result) so this endpoint can't be used to probe which emails are + // registered, already activated, deactivated, or currently throttled. + if (userId is null) return; + await ResendActivationEmailAsync(userId.Value, cancellationToken); + } + + /// + public async Task> ResendActivationEmailAsync(Guid userId, CancellationToken cancellationToken = default) + { + // Without mail no link can be delivered (and such accounts are activated on creation anyway). + if (!_mailOptions.IsEnabled) return new NotFound(); var user = await _db.Users - .Include(u => u.UserDeactivation) .Include(u => u.UserActivationRequest) - .FirstOrDefaultAsync(u => u.Email == lowerCaseEmail, cancellationToken); + .Include(u => u.UserDeactivation) + .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + if (user is null || user.ActivatedAt is not null) return new NotFound(); + if (user.UserDeactivation is not null) return new AccountDeactivated(); - // Silently no-op when there is nothing to send, so this endpoint can't be used to probe - // which emails are registered, already activated, or deactivated. - if (user is null || user.ActivatedAt is not null || user.UserDeactivation is not null) return; + // Throttle on the outbox itself (it is never auto-pruned), so the limit holds across API instances + // and restarts. The signup email counts towards it. A concurrent double-submit can slip one extra + // row past the check, but activation emails coalesce per user, so only the newest is delivered. + var coalesceKey = EmailOutboxCoalesceKeys.AccountActivation(userId); + var windowStart = DateTime.UtcNow - Duration.ActivationResendWindow; + var recent = await _db.EmailOutbox + .Where(m => m.CoalesceKey == coalesceKey && m.CreatedAt >= windowStart) + .Select(m => m.CreatedAt) + .ToListAsync(cancellationToken); - // Ensure an activation request exists (legacy data or a failed initial send may lack one). The - // real token is minted lazily by the outbox delivery job at send time, so we only seed the hash - // here; the seed is overwritten on send. - if (user.UserActivationRequest is null) + if (recent.Count >= 5 || recent.Any(createdAt => createdAt >= DateTime.UtcNow - Duration.ActivationResendCooldown)) + return new TooManyActivationResends(); + + user.UserActivationRequest ??= new UserActivationRequest { - user.UserActivationRequest = new UserActivationRequest - { - UserId = user.Id, - TokenHash = SeedTokenHash() - }; - } + UserId = user.Id, + TokenHash = SeedTokenHash(), + CreatedAt = DateTime.UtcNow + }; - // Durably enqueue a fresh activation email. Its coalesce key supersedes any still-pending - // activation for this user, and the delivery job re-mints the token on send - invalidating any - // previously emailed link. _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, user.Email, user.Name)); await _db.SaveChangesAsync(cancellationToken); await NotifyEmailOutboxAsync(); + + return new Success(); } /// diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index fba038a6..30fdf150 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -48,13 +48,23 @@ public interface IAccountService Task TryActivateAccountAsync(string token, CancellationToken cancellationToken = default); /// - /// Resends the account activation email for an unactivated account, rotating the activation token. - /// Silently does nothing when no email is needed (unknown email, already activated, or deactivated) - /// to avoid leaking account state. + /// Resends the account activation email for the unactivated account with this username or email. Silently + /// does nothing when no email is sent (unknown account, already activated, deactivated, or throttled) to + /// avoid leaking account state. Subject to the same per-user rate limit as the overload below. /// - /// The email address of the account to resend the activation email for. + /// The username or email address of the account. /// - Task ResendActivationEmailAsync(string email, CancellationToken cancellationToken = default); + Task ResendActivationEmailAsync(string usernameOrEmail, CancellationToken cancellationToken = default); + + /// + /// Re-enqueues the activation email for an unactivated, non-deactivated account. Only call this after the + /// caller has proven ownership (correct password or OAuth identity). Rate limited per user: one email per + /// 10 minutes, at most 5 per 24 hours (the signup email included). + /// + /// + /// + /// NotFound when there is nothing to resend (unknown user, already activated, or mail disabled). + Task> ResendActivationEmailAsync(Guid userId, CancellationToken cancellationToken = default); public Task> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater = true, string? reason = null, CancellationToken cancellationToken = default); @@ -180,6 +190,7 @@ public sealed class CannotDeactivatePrivilegedAccount; public sealed class AccountDeactivationAlreadyInProgress; public sealed class CannotDeletePrivilegedAccount; public sealed class TooManyPasswordResets; +public sealed class TooManyActivationResends; public sealed class SecretInvalid; public sealed class Unauthorized; diff --git a/API/Services/OAuthConnection/OAuthConnectionService.cs b/API/Services/OAuthConnection/OAuthConnectionService.cs index 98426cf8..545076ee 100644 --- a/API/Services/OAuthConnection/OAuthConnectionService.cs +++ b/API/Services/OAuthConnection/OAuthConnectionService.cs @@ -30,6 +30,8 @@ public IQueryable GetConnectionsAsNonTrackingQuery(Guid use { var p = provider.ToLowerInvariant(); return await _db.UserOAuthConnections + .Include(c => c.User) + .ThenInclude(u => u.UserDeactivation) .FirstOrDefaultAsync(c => c.ProviderKey == p && c.ExternalId == providerAccountId, cancellationToken); } diff --git a/Common/Constants/Constants.cs b/Common/Constants/Constants.cs index e8ffbbcd..659c9b30 100644 --- a/Common/Constants/Constants.cs +++ b/Common/Constants/Constants.cs @@ -9,6 +9,9 @@ public static class Duration public static readonly TimeSpan EmailChangeRequestLifetime = TimeSpan.FromHours(1); + public static readonly TimeSpan ActivationResendCooldown = TimeSpan.FromMinutes(10); + public static readonly TimeSpan ActivationResendWindow = TimeSpan.FromHours(24); + public static readonly TimeSpan NameChangeCooldown = TimeSpan.FromDays(7); public static readonly TimeSpan LoginSessionLifetime = TimeSpan.FromDays(30); From a6ee16c3b48c66aba6eecc7ba41a8a02954950e9 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 19:37:59 +0100 Subject: [PATCH 05/11] fix: serialize activation resends per user, make login resend best-effort - Lock the user row for the limit check and outbox insert so concurrent resends can't both pass the limit; notify the outbox after commit. - A failing resend during password or OAuth login is logged and no longer turns the AccountNotActivated response/redirect into a 500. --- API/Controller/Account/LoginV2.cs | 10 +++++++++- API/Controller/OAuth/HandOff.cs | 11 ++++++++++- API/Services/Account/AccountService.cs | 11 +++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 81bae07b..753ceb88 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -57,7 +57,15 @@ public async Task LoginV2( // same whether or not a mail went out. if (getAccountResult is AccountNotActivated) { - await _accountService.ResendActivationEmailAsync(body.UsernameOrEmail, cancellationToken); + try + { + await _accountService.ResendActivationEmailAsync(body.UsernameOrEmail, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Best effort: a failed resend must not turn the AccountNotActivated answer into a 500. + _logger.LogError(ex, "Failed to resend activation email on login"); + } } diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 8affc2bc..4c91b40e 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -88,7 +88,16 @@ await _accountService.IsEmailRegisteredAsync(externalEmail, cancellationToken)) { // The provider vouched for the identity, so the caller owns this account: (re)send the // activation link (rate limited inside the service). - await _accountService.ResendActivationEmailAsync(connection.UserId, cancellationToken); + try + { + await _accountService.ResendActivationEmailAsync(connection.UserId, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Best effort: still clear the flow cookie and redirect with the real reason. + _logger.LogError(ex, "Failed to resend activation email on OAuth login"); + } + await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); return RedirectFrontendError("accountNotActivated"); } diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index ec64b6bd..e656048f 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -302,6 +302,11 @@ public async Task u.UserActivationRequest) .Include(u => u.UserDeactivation) @@ -310,8 +315,7 @@ public async Task Date: Wed, 4 Nov 2026 21:30:10 +0100 Subject: [PATCH 06/11] fix: hide account-dependent failures from the public activation resend The resend-by-email path now logs and swallows failures from the per-user resend, so an existing account can't be told apart from an unknown email when the user-row lock or outbox insert fails. --- API/Services/Account/AccountService.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index e656048f..545bdfc7 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -293,7 +293,17 @@ public async Task ResendActivationEmailAsync(string usernameOrEmail, Cancellatio // Silently no-op (and ignore the result) so this endpoint can't be used to probe which emails are // registered, already activated, deactivated, or currently throttled. if (userId is null) return; - await ResendActivationEmailAsync(userId.Value, cancellationToken); + + try + { + await ResendActivationEmailAsync(userId.Value, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Only existing accounts reach this point, so a failure must look exactly like an unknown email to + // the caller - otherwise the public endpoint leaks which emails are registered. + _logger.LogError(ex, "Failed to resend activation email for user {UserId}", userId.Value); + } } /// From 6430200dc05de552343a48c81d162c3134a38624 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 21:46:02 +0100 Subject: [PATCH 07/11] feat: completing a password reset activates the account The reset link is delivered to the account's email, so completing it proves control of that inbox, which is all activation checks. Password reset can now be started for unactivated accounts; completing it sets ActivatedAt (if unset) and drops any pending activation request. This unblocks unactivated users who also forgot their password. --- API.IntegrationTests/Tests/MailTests.cs | 60 +++++++++++++++++++ .../Account/PasswordResetComplete.cs | 1 - API/Services/Account/AccountService.cs | 21 ++++--- API/Services/Account/IAccountService.cs | 7 ++- 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index e0f68084..2bd23a3e 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -4,6 +4,7 @@ using OpenShock.API.IntegrationTests.Helpers; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils; namespace OpenShock.API.IntegrationTests.Tests; @@ -312,6 +313,65 @@ public async Task V2PasswordReset_EnqueuesResetOutbox() await Assert.That(row.Payload[EmailOutboxPayloadKeys.PasswordResetId]).IsEqualTo(reset.Id.ToString()); } + [Test] + public async Task V2PasswordReset_UnactivatedUser_EnqueuesResetOutbox() + { + var email = TestHelper.UniqueEmail("mail-pwreset-unact"); + var username = TestHelper.UniqueUsername("mailpwresetunact"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#", activated: false); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new + { + email, + turnstileResponse = "valid-token" + })); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.EmailOutbox.AsNoTracking().SingleAsync(m => m.Recipient == email); + await Assert.That(row.Type).IsEqualTo(EmailType.PasswordReset); + await Assert.That(row.CoalesceKey).IsEqualTo(EmailOutboxCoalesceKeys.PasswordReset(userId)); + } + + [Test] + public async Task PasswordResetComplete_UnactivatedUser_ActivatesAccount() + { + var email = TestHelper.UniqueEmail("mail-pwreset-activates"); + var username = TestHelper.UniqueUsername("mailpwresetact"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#", activated: false); + + // Stand in for the Cron delivery job: seed the activation request and a reset with a known secret. + const string secret = "test-password-reset-secret"; + var resetId = Guid.CreateVersion7(); + await using (var seedScope = WebApplicationFactory.Services.CreateAsyncScope()) + { + var seedDb = seedScope.ServiceProvider.GetRequiredService(); + var user = await seedDb.Users.FirstAsync(u => u.Id == userId); + seedDb.UserActivationRequests.Add(new UserActivationRequest { UserId = userId, TokenHash = HashingUtils.HashToken("unused-activation-secret") }); + seedDb.UserPasswordResets.Add(new UserPasswordReset + { + Id = resetId, + UserId = userId, + TokenHash = HashingUtils.HashToken(secret), + SecurityStampAtCreate = user.SecurityStamp + }); + await seedDb.SaveChangesAsync(); + } + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync($"/1/account/password-reset/{resetId}/{secret}/complete", + TestHelper.JsonContent(new { password = "NewPassword123#" })); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var activatedAt = await db.Users.AsNoTracking().Where(u => u.Id == userId).Select(u => u.ActivatedAt).SingleAsync(); + await Assert.That(activatedAt).IsNotNull(); + await Assert.That(await db.UserActivationRequests.AsNoTracking().AnyAsync(r => r.UserId == userId)).IsFalse(); + } + [Test] public async Task PasswordResetComplete_LegacyRecoverRoute_Returns410Gone() { diff --git a/API/Controller/Account/PasswordResetComplete.cs b/API/Controller/Account/PasswordResetComplete.cs index 6a984690..0c6b0fe4 100644 --- a/API/Controller/Account/PasswordResetComplete.cs +++ b/API/Controller/Account/PasswordResetComplete.cs @@ -53,7 +53,6 @@ public async Task PasswordResetComplete([FromRoute] Guid password { Results.Success => LegacyEmptyOk("Password successfully changed"), Results.NotFound => Problem(PasswordResetError.PasswordResetNotFound), - AccountNotActivated => Problem(AccountError.AccountNotActivated), AccountDeactivated => Problem(AccountError.AccountDeactivated), SecretInvalid => Problem(PasswordResetError.PasswordResetNotFound), _ => throw new UnreachableException() diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 545bdfc7..9296a64b 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -559,7 +559,7 @@ public async Task> CheckPasswordResetEx } /// - public async Task> CreatePasswordResetFlowAsync(string email, CancellationToken cancellationToken) + public async Task> CreatePasswordResetFlowAsync(string email, CancellationToken cancellationToken) { var validSince = DateTime.UtcNow - Duration.PasswordResetRequestLifetime; var lowerCaseEmail = email.ToLowerInvariant(); @@ -573,7 +573,8 @@ public async Task= 3) return new TooManyPasswordResets(); @@ -596,7 +597,7 @@ public async Task - public async Task> CompletePasswordResetFlowAsync(Guid passwordResetId, + public async Task> CompletePasswordResetFlowAsync(Guid passwordResetId, string secret, string newPassword, CancellationToken cancellationToken) { @@ -606,14 +607,12 @@ public async Task 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, cancellationToken); if (reset is null) return new NotFound(); - if (reset.UserActivatedAt is null) return new AccountNotActivated(); if (reset.IsDeactivated) return new AccountDeactivated(); var result = HashingUtils.VerifyToken(secret, reset.Reset.TokenHash); @@ -624,16 +623,24 @@ public async Task u.Id == reset.Reset.UserId && u.SecurityStamp == reset.Reset.SecurityStampAtCreate) .ExecuteUpdateAsync(s => s .SetProperty(u => u.PasswordHash, newPasswordHash) - .SetProperty(u => u.SecurityStamp, newStamp), cancellationToken); + .SetProperty(u => u.SecurityStamp, newStamp) + .SetProperty(u => u.ActivatedAt, u => u.ActivatedAt ?? now), cancellationToken); if (userRows == 0) return new NotFound(); - var now = DateTime.UtcNow; + // Any pending activation request is moot now; dropping it also makes the outbox skip a queued activation email. + await _db.UserActivationRequests + .Where(r => r.UserId == reset.Reset.UserId) + .ExecuteDeleteAsync(cancellationToken); + await _db.UserPasswordResets .Where(r => r.Id == reset.Reset.Id && r.UsedAt == null) .ExecuteUpdateAsync(s => s.SetProperty(r => r.UsedAt, now), cancellationToken); diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 30fdf150..8d0369bd 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -96,17 +96,18 @@ public interface IAccountService /// /// /// - public Task> CreatePasswordResetFlowAsync(string email, CancellationToken cancellationToken = default); + public Task> CreatePasswordResetFlowAsync(string email, CancellationToken cancellationToken = default); /// - /// Completes a password reset process, sets a new password + /// Completes a password reset process, sets a new password. Since the reset link proves control of the + /// account's email, this also activates an unactivated account. /// /// /// /// /// /// - public Task> CompletePasswordResetFlowAsync(Guid passwordResetId, string secret, string newPassword, CancellationToken cancellationToken = default); + public Task> CompletePasswordResetFlowAsync(Guid passwordResetId, string secret, string newPassword, CancellationToken cancellationToken = default); /// /// Check the availability of a email From de288d85f8ee538137870886d58324287d6a11f6 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 21:51:52 +0100 Subject: [PATCH 08/11] feat: identify activation resend by a signup cookie instead of an email Signup (password and OAuth) sets an HttpOnly, time-limited data protection cookie holding the new user's id when the account still needs activation. POST /1/account/activate/resend now reads that cookie instead of an email body, so it can only target an account created from this browser and can't probe registered emails; Turnstile is no longer needed. It returns 429 when throttled and 400 (clearing the cookie) once there is nothing to resend. The cookie is removed on any successful session authentication. --- API.IntegrationTests/Tests/MailTests.cs | 208 +++++++++++------- API/Controller/Account/ResendActivation.cs | 53 +++-- API/Controller/Account/SignupV2.cs | 11 +- API/Controller/OAuth/SignupFinalize.cs | 8 +- API/Services/Account/AccountService.cs | 18 +- API/Services/Account/IAccountService.cs | 10 +- .../Authentication/ActivationResendCookie.cs | 77 +++++++ .../UserSessionAuthentication.cs | 3 + Common/Constants/AuthConstants.cs | 1 + Common/Errors/AccountError.cs | 2 + Common/OpenShockControllerBase.cs | 6 +- 11 files changed, 267 insertions(+), 130 deletions(-) create mode 100644 Common/Authentication/ActivationResendCookie.cs diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 2bd23a3e..de3f3260 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -2,6 +2,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using OpenShock.API.IntegrationTests.Helpers; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc.Testing; +using OpenShock.Common.Authentication; +using OpenShock.Common.Constants; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Utils; @@ -51,17 +55,28 @@ public async Task V2Signup_EnqueuesActivationOutbox() // --- Resend Activation --- + [Test] + public async Task V2Signup_Unactivated_SetsActivationResendCookie() + { + using var client = CreateCookielessClient(); + var response = await SignUpAsync(client, TestHelper.UniqueEmail("mail-signup-cookie"), TestHelper.UniqueUsername("mailsignupcookie")); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var setCookie = response.Headers.GetValues("Set-Cookie").ToArray(); + await Assert.That(setCookie.Any(c => c.StartsWith(AuthConstants.ActivationResendCookieName + "=") && c.Contains("httponly", StringComparison.OrdinalIgnoreCase))).IsTrue(); + } + [Test] public async Task ResendActivation_UnactivatedUser_EnqueuesActivationOutbox() { var email = TestHelper.UniqueEmail("mail-resend-activate"); var username = TestHelper.UniqueUsername("mailresendactivate"); - // Unactivated user with no existing activation request — exercises the create-request path. + // Unactivated user with no existing activation request - exercises the create-request path. var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); - using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); + using var client = CreateCookielessClient(); + var response = await PostResendAsync(client, MintResendCookie(userId)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); @@ -78,28 +93,25 @@ public async Task ResendActivation_UnactivatedUser_EnqueuesActivationOutbox() } [Test] - public async Task ResendActivation_WithExistingRequest_EnqueuesAnotherActivationOutbox() + public async Task ResendActivation_WithSignupCookie_EnqueuesAnotherActivationOutbox() { var email = TestHelper.UniqueEmail("mail-resend-rotate"); var username = TestHelper.UniqueUsername("mailresendrotate"); - using var client = WebApplicationFactory.CreateClient(); + using var client = CreateCookielessClient(); - // Sign up (V2) — creates an unactivated user, its activation request, and the first enqueue. - var signupResponse = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new - { - username, - password = "SecurePassword123#", - email, - turnstileResponse = "valid-token" - })); + // Sign up (V2) - creates an unactivated user, its activation request, the first enqueue, and the cookie. + var signupResponse = await SignUpAsync(client, email, username); await Assert.That(signupResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + var cookie = signupResponse.Headers.GetValues("Set-Cookie") + .Single(c => c.StartsWith(AuthConstants.ActivationResendCookieName + "=")) + .Split(';')[0]; // Move the signup email out of the resend cooldown. await BackdateOutboxAsync(email, TimeSpan.FromMinutes(11)); // Resend enqueues another activation email. The delivery job re-mints the token and supersedes the // older row at send time (coalesce key) - that token rotation is covered in Cron.IntegrationTests. - var resendResponse = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); + var resendResponse = await PostResendAsync(client, cookie); await Assert.That(resendResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); @@ -113,41 +125,95 @@ public async Task ResendActivation_WithExistingRequest_EnqueuesAnotherActivation } [Test] - public async Task ResendActivation_WithinCooldown_Returns200_AndEnqueuesNothing() + public async Task ResendActivation_WithinCooldown_Returns429_AndEnqueuesNothing() { var email = TestHelper.UniqueEmail("mail-resend-cooldown"); var username = TestHelper.UniqueUsername("mailresendcooldown"); - await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + var cookie = MintResendCookie(userId); - using var client = WebApplicationFactory.CreateClient(); - for (var i = 0; i < 3; i++) - { - var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - } + using var client = CreateCookielessClient(); + await Assert.That((await PostResendAsync(client, cookie)).StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That((await PostResendAsync(client, cookie)).StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests); await Assert.That(await CountOutboxAsync(email)).IsEqualTo(1); } [Test] - public async Task ResendActivation_DailyCapReached_EnqueuesNothing() + public async Task ResendActivation_DailyCapReached_Returns429() { var email = TestHelper.UniqueEmail("mail-resend-cap"); var username = TestHelper.UniqueUsername("mailresendcap"); - await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + var cookie = MintResendCookie(userId); - using var client = WebApplicationFactory.CreateClient(); - for (var i = 0; i < 6; i++) + using var client = CreateCookielessClient(); + for (var i = 0; i < 5; i++) { - var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That((await PostResendAsync(client, cookie)).StatusCode).IsEqualTo(HttpStatusCode.OK); // Step past the cooldown each time; only the 24h cap of 5 should stop the sixth. await BackdateOutboxAsync(email, TimeSpan.FromMinutes(11)); } + await Assert.That((await PostResendAsync(client, cookie)).StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests); await Assert.That(await CountOutboxAsync(email)).IsEqualTo(5); } + [Test] + public async Task ResendActivation_AlreadyActivatedUser_Returns400_AndRemovesCookie() + { + var email = TestHelper.UniqueEmail("mail-resend-activated"); + var username = TestHelper.UniqueUsername("mailresendactivated"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: true); + + using var client = CreateCookielessClient(); + var response = await PostResendAsync(client, MintResendCookie(userId)); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(DeletesResendCookie(response)).IsTrue(); + + await Assert.That(await CountOutboxAsync(email)).IsEqualTo(0); + } + + [Test] + public async Task ResendActivation_NoCookie_Returns400() + { + using var client = CreateCookielessClient(); + var response = await PostResendAsync(client, cookie: null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task ResendActivation_TamperedCookie_Returns400() + { + using var client = CreateCookielessClient(); + var response = await PostResendAsync(client, $"{AuthConstants.ActivationResendCookieName}=not-a-valid-token"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Login_Success_RemovesActivationResendCookie() + { + var email = TestHelper.UniqueEmail("mail-login-clears"); + var username = TestHelper.UniqueUsername("maillogclears"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: true); + + using var client = CreateCookielessClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/2/account/login") + { + Content = TestHelper.JsonContent(new + { + usernameOrEmail = email, + password = "SecurePassword123#", + turnstileResponse = "valid-token" + }) + }; + request.Headers.Add("Cookie", MintResendCookie(userId)); + + var response = await client.SendAsync(request); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(DeletesResendCookie(response)).IsTrue(); + } + [Test] public async Task Login_UnactivatedUser_CorrectPassword_ReturnsNotActivated_AndEnqueuesActivation() { @@ -191,76 +257,54 @@ public async Task Login_UnactivatedUser_WrongPassword_EnqueuesNothing() await Assert.That(await CountOutboxAsync(email)).IsEqualTo(0); } - private async Task CountOutboxAsync(string email) + private HttpClient CreateCookielessClient() => WebApplicationFactory.CreateClient(new WebApplicationFactoryClientOptions { - await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - return await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email); - } + AllowAutoRedirect = false, + HandleCookies = false + }); - private async Task BackdateOutboxAsync(string email, TimeSpan by) - { - await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.EmailOutbox - .Where(m => m.Recipient == email) - .ExecuteUpdateAsync(s => s.SetProperty(m => m.CreatedAt, m => m.CreatedAt - by)); - } + private static Task SignUpAsync(HttpClient client, string email, string username) => + client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username, + password = "SecurePassword123#", + email, + turnstileResponse = "valid-token" + })); - [Test] - public async Task ResendActivation_AlreadyActivatedUser_Returns200_AndEnqueuesNothing() + private string MintResendCookie(Guid userId) { - var email = TestHelper.UniqueEmail("mail-resend-activated"); - var username = TestHelper.UniqueUsername("mailresendactivated"); - - await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: true); - - using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); - - // Generic 200 (no account-state leak), but nothing is enqueued for an already-activated account. - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - - await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var enqueued = await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email); - await Assert.That(enqueued).IsEqualTo(0); + var provider = WebApplicationFactory.Services.GetRequiredService(); + return $"{AuthConstants.ActivationResendCookieName}={ActivationResendCookie.CreateToken(provider, userId)}"; } - [Test] - public async Task ResendActivation_UnknownEmail_Returns200_AndEnqueuesNothing() + private static Task PostResendAsync(HttpClient client, string? cookie) { - var email = TestHelper.UniqueEmail("mail-resend-unknown"); - - using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); + var request = new HttpRequestMessage(HttpMethod.Post, "/1/account/activate/resend"); + if (cookie is not null) request.Headers.Add("Cookie", cookie); + return client.SendAsync(request); + } - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + // A deleted cookie comes back as an empty value with an expiry in the past. + private static bool DeletesResendCookie(HttpResponseMessage response) => + response.Headers.TryGetValues("Set-Cookie", out var values) && + values.Any(c => c.StartsWith(AuthConstants.ActivationResendCookieName + "=;") && + c.Contains("expires=Thu, 01 Jan 1970", StringComparison.OrdinalIgnoreCase)); + private async Task CountOutboxAsync(string email) + { await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - var enqueued = await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email); - await Assert.That(enqueued).IsEqualTo(0); + return await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email); } - [Test] - public async Task ResendActivation_InvalidTurnstile_Returns403_AndEnqueuesNothing() + private async Task BackdateOutboxAsync(string email, TimeSpan by) { - var email = TestHelper.UniqueEmail("mail-resend-turnstile"); - var username = TestHelper.UniqueUsername("mailresendturnstile"); - - await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); - - using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/1/account/activate/resend", TestHelper.JsonContent(new { email, turnstileResponse = "invalid-token" })); - - // A failed turnstile must stop the send outright - this endpoint mails a third party. - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); - await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - var enqueued = await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email); - await Assert.That(enqueued).IsEqualTo(0); + await db.EmailOutbox + .Where(m => m.Recipient == email) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.CreatedAt, m => m.CreatedAt - by)); } // --- Password Reset --- diff --git a/API/Controller/Account/ResendActivation.cs b/API/Controller/Account/ResendActivation.cs index 10ded8c2..7e4330c1 100644 --- a/API/Controller/Account/ResendActivation.cs +++ b/API/Controller/Account/ResendActivation.cs @@ -1,46 +1,51 @@ -using System.ComponentModel.DataAnnotations; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Services.Turnstile; -using OpenShock.Common.Constants; -using OpenShock.Common.DataAnnotations; +using OpenShock.API.Services.Account; +using OpenShock.Common.Authentication; +using OpenShock.Common.Errors; using OpenShock.Common.Problems; using OpenShock.Internal.Common.Problems; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { /// - /// Resend the account activation email + /// Resend the account activation email for the account this browser just signed up /// - /// Activation email sent if the email is associated to an unactivated account + /// + /// Identifies the account by the HttpOnly activation-resend cookie set on signup, not by an email address, + /// so it can only target an account created from this browser and can't be used to probe registered emails. + /// + /// Activation email enqueued + /// No pending activation for this browser (missing/expired cookie, or already activated) + /// An activation email was sent too recently [HttpPost("activate/resend")] [EnableRateLimiting("auth")] - [Consumes(MediaTypeNames.Application.Json)] [MapToApiVersion("1")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] - public async Task ResendActivation([FromBody] ResendActivationRequest body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // AccountActivationResendUnavailable + [ProducesResponseType(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // AccountActivationResendTooMany + public async Task ResendActivation(CancellationToken cancellationToken) { - // Unauthenticated and it puts mail in a third party's inbox, so it carries the same turnstile - // requirement as password-reset initiation; the rate limiter alone gates nothing but volume. - var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); - if (turnstileError is not null) return turnstileError; + if (!ActivationResendCookie.TryGetUserId(HttpContext, out var userId)) + return Problem(AccountError.AccountActivationResendUnavailable); - await _accountService.ResendActivationEmailAsync(body.Email, cancellationToken); - return Ok(); - } - - public sealed class ResendActivationRequest - { - [OpenShock.Common.DataAnnotations.EmailAddress(true)] - public required string Email { get; init; } + var result = await _accountService.ResendActivationEmailAsync(userId.Value, cancellationToken); - [Required(AllowEmptyStrings = false)] - [StringLength(ApiHardLimits.MaxTurnstileResponseTokenLength)] - public required string TurnstileResponse { get; init; } + switch (result) + { + case Results.Success: + return Ok(); + case TooManyActivationResends: + return Problem(AccountError.AccountActivationResendTooMany); + default: + // Already activated, deactivated, or gone: the cookie has nothing left to do. + ActivationResendCookie.Remove(HttpContext); + return Problem(AccountError.AccountActivationResendUnavailable); + } } } diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index f666a0a5..c1432feb 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using OpenShock.Common.Authentication; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using OpenShock.API.Services.Account; using System.Diagnostics; @@ -49,7 +50,7 @@ public async Task SignUpV2( // Created with an automation token, the account is linked to it and the use audited in the same transaction. var creationAction = await _accountService.CreateAccountWithActivationFlowAsync( body.Email, body.Username, body.Password, cancellationToken); - if (creationAction is not User _) + if (creationAction is not User newUser) { return creationAction switch { @@ -58,6 +59,12 @@ public async Task SignUpV2( }; } + // Lets this browser ask for the activation email again without typing the address. + if (newUser.ActivatedAt is null && GetCurrentCookieDomain() is { } cookieDomain) + { + ActivationResendCookie.Set(HttpContext, newUser.Id, cookieDomain); + } + return Ok(); } } \ No newline at end of file diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs index 659b0cf5..c352e59a 100644 --- a/API/Controller/OAuth/SignupFinalize.cs +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authentication; +using OpenShock.Common.Authentication; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Models.Requests; @@ -115,6 +116,11 @@ public async Task OAuthSignupFinalize( { await CreateSession(newUser.Id, domain, cancellationToken); } + else + { + // Lets this browser ask for the activation email again without typing the address. + ActivationResendCookie.Set(HttpContext, newUser.Id, domain); + } // Clear the temporary OAuth flow cookie. await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 9296a64b..f4142ff4 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -281,7 +281,7 @@ public async Task TryActivateAccountAsync(string secret, CancellationToken } /// - public async Task ResendActivationEmailAsync(string usernameOrEmail, CancellationToken cancellationToken = default) + public async Task> ResendActivationEmailAsync(string usernameOrEmail, CancellationToken cancellationToken = default) { var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant(); @@ -289,21 +289,9 @@ public async Task ResendActivationEmailAsync(string usernameOrEmail, Cancellatio .Where(u => u.Email == lowercaseUsernameOrEmail || u.Name == lowercaseUsernameOrEmail) .Select(u => (Guid?)u.Id) .FirstOrDefaultAsync(cancellationToken); + if (userId is null) return new NotFound(); - // Silently no-op (and ignore the result) so this endpoint can't be used to probe which emails are - // registered, already activated, deactivated, or currently throttled. - if (userId is null) return; - - try - { - await ResendActivationEmailAsync(userId.Value, cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // Only existing accounts reach this point, so a failure must look exactly like an unknown email to - // the caller - otherwise the public endpoint leaks which emails are registered. - _logger.LogError(ex, "Failed to resend activation email for user {UserId}", userId.Value); - } + return await ResendActivationEmailAsync(userId.Value, cancellationToken); } /// diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 8d0369bd..3e19c853 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -48,13 +48,13 @@ public interface IAccountService Task TryActivateAccountAsync(string token, CancellationToken cancellationToken = default); /// - /// Resends the account activation email for the unactivated account with this username or email. Silently - /// does nothing when no email is sent (unknown account, already activated, deactivated, or throttled) to - /// avoid leaking account state. Subject to the same per-user rate limit as the overload below. + /// Looks up the account by username or email and re-enqueues its activation email, see the overload below. + /// Only call this after the caller has proven ownership (e.g. a correct password), as the result reveals + /// account state. /// - /// The username or email address of the account. + /// /// - Task ResendActivationEmailAsync(string usernameOrEmail, CancellationToken cancellationToken = default); + Task> ResendActivationEmailAsync(string usernameOrEmail, CancellationToken cancellationToken = default); /// /// Re-enqueues the activation email for an unactivated, non-deactivated account. Only call this after the diff --git a/Common/Authentication/ActivationResendCookie.cs b/Common/Authentication/ActivationResendCookie.cs new file mode 100644 index 00000000..b73880b4 --- /dev/null +++ b/Common/Authentication/ActivationResendCookie.cs @@ -0,0 +1,77 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.Common.Constants; +using OpenShock.Common.Options; + +namespace OpenShock.Common.Authentication; + +/// +/// HttpOnly cookie set on signup when the new account still needs activation. It lets the browser that created +/// the account ask for the activation email again ("didn't get the email?") without typing an address, so the +/// resend endpoint can't be aimed at arbitrary inboxes or used to probe which emails are registered. +/// +/// +/// The value is a time-limited data protection payload holding the user id (keys are persisted to the database, +/// so it is valid across API instances). It is removed on any successful session authentication, and by the +/// resend endpoint once there is nothing left to resend. +/// +public static class ActivationResendCookie +{ + private const string ProtectorPurpose = "OpenShock.ActivationResendCookie"; + + public static string CreateToken(IDataProtectionProvider provider, Guid userId) + => GetProtector(provider).Protect(userId.ToString(), Duration.ActivationResendWindow); + + public static void Set(HttpContext context, Guid userId, string domain) + { + var provider = context.RequestServices.GetRequiredService(); + var frontendOptions = context.RequestServices.GetRequiredService(); + + context.Response.Cookies.Append(AuthConstants.ActivationResendCookieName, CreateToken(provider, userId), new CookieOptions + { + Expires = DateTimeOffset.UtcNow.Add(Duration.ActivationResendWindow), + Secure = context.Request.IsHttps || frontendOptions.CookieSecure, + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Domain = domain + }); + } + + public static bool TryGetUserId(HttpContext context, [NotNullWhen(true)] out Guid? userId) + { + userId = null; + if (!context.Request.Cookies.TryGetValue(AuthConstants.ActivationResendCookieName, out var token) || string.IsNullOrEmpty(token)) + return false; + + var provider = context.RequestServices.GetRequiredService(); + try + { + if (!Guid.TryParse(GetProtector(provider).Unprotect(token), out var id)) return false; + userId = id; + return true; + } + catch (CryptographicException) + { + // Tampered, expired, or signed with a revoked key. + return false; + } + } + + /// Deletes the cookie, if the request carries one, on every configured cookie domain. + public static void Remove(HttpContext context) + { + if (!context.Request.Cookies.ContainsKey(AuthConstants.ActivationResendCookieName)) return; + + var cookieDomains = context.RequestServices.GetRequiredService().CookieDomains; + foreach (var domain in cookieDomains) + { + context.Response.Cookies.Delete(AuthConstants.ActivationResendCookieName, new CookieOptions { Domain = domain }); + } + } + + private static ITimeLimitedDataProtector GetProtector(IDataProtectionProvider provider) + => provider.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector(); +} diff --git a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs index ee028795..698ec2c9 100644 --- a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs @@ -73,6 +73,9 @@ protected override async Task HandleAuthenticateAsync() Context.SetItemByType(session); Context.SetItemByType(user); + // An authenticated (hence activated) session has no use for the post-signup resend cookie. + ActivationResendCookie.Remove(Context); + var claims = new List(2 + user.Roles.Count) { new(ClaimTypes.AuthenticationMethod, OpenShockAuthSchemes.UserSessionCookie), diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index 91d36e13..3fdb5c6a 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -3,6 +3,7 @@ public static class AuthConstants { public const string UserSessionCookieName = "openShockSession"; + public const string ActivationResendCookieName = "openShockActivationResend"; public const string UserSessionHeaderName = "OpenShockSession"; public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; diff --git a/Common/Errors/AccountError.cs b/Common/Errors/AccountError.cs index b6051d51..50b4374c 100644 --- a/Common/Errors/AccountError.cs +++ b/Common/Errors/AccountError.cs @@ -28,6 +28,8 @@ public static class AccountError "Account.Username.RecentlyChanged", "You have recently changed your username. You can only change your username every 7 days", HttpStatusCode.Forbidden); public static OpenShockProblem AccountNotActivated => new OpenShockProblem("Account.AccountNotActivated", "Your account has not been activated", HttpStatusCode.Unauthorized); + public static OpenShockProblem AccountActivationResendUnavailable => new OpenShockProblem("Account.Activation.ResendUnavailable", "There is no pending activation to resend for this browser. Try logging in to get a new activation email", HttpStatusCode.BadRequest); + public static OpenShockProblem AccountActivationResendTooMany => new OpenShockProblem("Account.Activation.ResendTooMany", "An activation email was sent recently. Please wait a few minutes before requesting another", HttpStatusCode.TooManyRequests); public static OpenShockProblem AccountActivationNotFound => new OpenShockProblem("Account.Activation.NotFound", "There is no account activation request matching the supplied token", HttpStatusCode.BadRequest); public static OpenShockProblem AccountDeactivated => new OpenShockProblem("Account.Deactivated", "Your account has been deactivated", HttpStatusCode.Unauthorized); diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index c2f60715..ab9f944b 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using OpenShock.Common.Authentication; +using Microsoft.AspNetCore.Mvc; using OpenShock.Common.Constants; using OpenShock.Common.Extensions; using OpenShock.Common.Models; @@ -79,6 +80,9 @@ protected async Task CreateSession(Guid accountId, string domain, CancellationTo SameSite = SameSiteMode.Lax, Domain = domain }); + + // Signed in, so the post-signup "didn't get the email?" cookie has served its purpose. + ActivationResendCookie.Remove(HttpContext); } [NonAction] From 3cc552a610c95ab925265f7ed65357cfd9d6793e Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 21:56:39 +0100 Subject: [PATCH 09/11] fix: make the activation resend cookie readable by the frontend The frontend shows the resend button only while the cookie exists, so it can't be HttpOnly. HttpOnly bought little here: page script could call the endpoint with the cookie attached anyway, and a leaked token can only resend rate-limited activation emails to that account's own inbox. The resend endpoint now also deletes a tampered or expired cookie, so the frontend stops offering the button. --- API.IntegrationTests/Tests/MailTests.cs | 7 +++++-- API/Controller/Account/ResendActivation.cs | 6 +++++- Common/Authentication/ActivationResendCookie.cs | 13 +++++++++---- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index de3f3260..db5c3053 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -63,7 +63,9 @@ public async Task V2Signup_Unactivated_SetsActivationResendCookie() await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); var setCookie = response.Headers.GetValues("Set-Cookie").ToArray(); - await Assert.That(setCookie.Any(c => c.StartsWith(AuthConstants.ActivationResendCookieName + "=") && c.Contains("httponly", StringComparison.OrdinalIgnoreCase))).IsTrue(); + var resendCookie = setCookie.Single(c => c.StartsWith(AuthConstants.ActivationResendCookieName + "=")); + // Readable by the frontend, which shows the resend button only while the cookie exists. + await Assert.That(resendCookie.Contains("httponly", StringComparison.OrdinalIgnoreCase)).IsFalse(); } [Test] @@ -183,11 +185,12 @@ public async Task ResendActivation_NoCookie_Returns400() } [Test] - public async Task ResendActivation_TamperedCookie_Returns400() + public async Task ResendActivation_TamperedCookie_Returns400_AndRemovesCookie() { using var client = CreateCookielessClient(); var response = await PostResendAsync(client, $"{AuthConstants.ActivationResendCookieName}=not-a-valid-token"); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(DeletesResendCookie(response)).IsTrue(); } [Test] diff --git a/API/Controller/Account/ResendActivation.cs b/API/Controller/Account/ResendActivation.cs index 7e4330c1..2ced554c 100644 --- a/API/Controller/Account/ResendActivation.cs +++ b/API/Controller/Account/ResendActivation.cs @@ -17,7 +17,7 @@ public sealed partial class AccountController /// Resend the account activation email for the account this browser just signed up /// /// - /// Identifies the account by the HttpOnly activation-resend cookie set on signup, not by an email address, + /// Identifies the account by the activation-resend cookie set on signup, not by an email address, /// so it can only target an account created from this browser and can't be used to probe registered emails. /// /// Activation email enqueued @@ -32,7 +32,11 @@ public sealed partial class AccountController public async Task ResendActivation(CancellationToken cancellationToken) { if (!ActivationResendCookie.TryGetUserId(HttpContext, out var userId)) + { + // Tampered or expired (a no-op when there is no cookie), so the frontend stops offering the button. + ActivationResendCookie.Remove(HttpContext); return Problem(AccountError.AccountActivationResendUnavailable); + } var result = await _accountService.ResendActivationEmailAsync(userId.Value, cancellationToken); diff --git a/Common/Authentication/ActivationResendCookie.cs b/Common/Authentication/ActivationResendCookie.cs index b73880b4..5dd106da 100644 --- a/Common/Authentication/ActivationResendCookie.cs +++ b/Common/Authentication/ActivationResendCookie.cs @@ -9,14 +9,19 @@ namespace OpenShock.Common.Authentication; /// -/// HttpOnly cookie set on signup when the new account still needs activation. It lets the browser that created -/// the account ask for the activation email again ("didn't get the email?") without typing an address, so the -/// resend endpoint can't be aimed at arbitrary inboxes or used to probe which emails are registered. +/// Cookie set on signup when the new account still needs activation. It lets the browser that created the account +/// ask for the activation email again ("didn't get the email?") without typing an address, so the resend endpoint +/// can't be aimed at arbitrary inboxes or used to probe which emails are registered. /// /// /// The value is a time-limited data protection payload holding the user id (keys are persisted to the database, /// so it is valid across API instances). It is removed on any successful session authentication, and by the /// resend endpoint once there is nothing left to resend. +/// +/// Deliberately not HttpOnly: the frontend reads its presence to decide whether to offer the resend +/// button. HttpOnly would buy little anyway - script on the page could call the endpoint with the cookie attached +/// regardless - and a leaked token can only resend (rate limited) activation emails to that account's own inbox. +/// /// public static class ActivationResendCookie { @@ -34,7 +39,7 @@ public static void Set(HttpContext context, Guid userId, string domain) { Expires = DateTimeOffset.UtcNow.Add(Duration.ActivationResendWindow), Secure = context.Request.IsHttps || frontendOptions.CookieSecure, - HttpOnly = true, + HttpOnly = false, SameSite = SameSiteMode.Lax, Domain = domain }); From cfbba98f24dd465c4c7239b4fdafbdd719748ffa Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 22:02:14 +0100 Subject: [PATCH 10/11] fix: neutral wording for activation resend errors ResendUnavailable covers invalid cookies and activated, deactivated or missing accounts, so it no longer suggests a remedy. ResendTooMany covers both the 10-minute cooldown and the daily cap, so it no longer promises a short wait. --- Common/Errors/AccountError.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Common/Errors/AccountError.cs b/Common/Errors/AccountError.cs index 50b4374c..25bcd8cf 100644 --- a/Common/Errors/AccountError.cs +++ b/Common/Errors/AccountError.cs @@ -28,8 +28,8 @@ public static class AccountError "Account.Username.RecentlyChanged", "You have recently changed your username. You can only change your username every 7 days", HttpStatusCode.Forbidden); public static OpenShockProblem AccountNotActivated => new OpenShockProblem("Account.AccountNotActivated", "Your account has not been activated", HttpStatusCode.Unauthorized); - public static OpenShockProblem AccountActivationResendUnavailable => new OpenShockProblem("Account.Activation.ResendUnavailable", "There is no pending activation to resend for this browser. Try logging in to get a new activation email", HttpStatusCode.BadRequest); - public static OpenShockProblem AccountActivationResendTooMany => new OpenShockProblem("Account.Activation.ResendTooMany", "An activation email was sent recently. Please wait a few minutes before requesting another", HttpStatusCode.TooManyRequests); + public static OpenShockProblem AccountActivationResendUnavailable => new OpenShockProblem("Account.Activation.ResendUnavailable", "No activation email can be resent from this browser", HttpStatusCode.BadRequest); + public static OpenShockProblem AccountActivationResendTooMany => new OpenShockProblem("Account.Activation.ResendTooMany", "Too many activation emails have been requested. Please try again later", HttpStatusCode.TooManyRequests); public static OpenShockProblem AccountActivationNotFound => new OpenShockProblem("Account.Activation.NotFound", "There is no account activation request matching the supplied token", HttpStatusCode.BadRequest); public static OpenShockProblem AccountDeactivated => new OpenShockProblem("Account.Deactivated", "Your account has been deactivated", HttpStatusCode.Unauthorized); From ba2ae50014f796a1cdf2ffebfaa519b3eb15a6ca Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 4 Nov 2026 22:07:35 +0100 Subject: [PATCH 11/11] refactor: use a switch expression for the activation resend result Matches how the other account controllers map service unions: explicit arms plus an UnreachableException fallback instead of a default case. --- API/Controller/Account/ResendActivation.cs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/API/Controller/Account/ResendActivation.cs b/API/Controller/Account/ResendActivation.cs index 2ced554c..c192e083 100644 --- a/API/Controller/Account/ResendActivation.cs +++ b/API/Controller/Account/ResendActivation.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; @@ -40,16 +41,16 @@ public async Task ResendActivation(CancellationToken cancellation var result = await _accountService.ResendActivationEmailAsync(userId.Value, cancellationToken); - switch (result) + // Already activated, deactivated, or gone: the cookie has nothing left to do. + if (result is AccountDeactivated or Results.NotFound) + ActivationResendCookie.Remove(HttpContext); + + return result switch { - case Results.Success: - return Ok(); - case TooManyActivationResends: - return Problem(AccountError.AccountActivationResendTooMany); - default: - // Already activated, deactivated, or gone: the cookie has nothing left to do. - ActivationResendCookie.Remove(HttpContext); - return Problem(AccountError.AccountActivationResendUnavailable); - } + Results.Success => Ok(), + TooManyActivationResends => Problem(AccountError.AccountActivationResendTooMany), + AccountDeactivated or Results.NotFound => Problem(AccountError.AccountActivationResendUnavailable), + _ => throw new UnreachableException() + }; } }