From 105d509430a491d55cb6b4c8402ecd530669b666 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 24 Jun 2026 09:57:30 +0200 Subject: [PATCH 1/2] 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 2/2] 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