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