Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions API.IntegrationTests/Tests/MailTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenShockContext>();
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]
Expand Down
32 changes: 32 additions & 0 deletions API/Controller/Account/ResendActivation.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Resend the account activation email
/// </summary>
/// <response code="200">Activation email sent if the email is associated to an unactivated account</response>
[HttpPost("activate/resend")]
[EnableRateLimiting("auth")]
[Consumes(MediaTypeNames.Application.Json)]
[MapToApiVersion("1")]
[ProducesResponseType<LegacyEmptyResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<IActionResult> 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; }
}
}
41 changes: 41 additions & 0 deletions API/Services/Account/AccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,47 @@ public async Task<bool> TryActivateAccountAsync(string secret, CancellationToken
return true;
}

/// <inheritdoc />
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);
}

/// <inheritdoc />
public async Task<OneOf<Success, CannotDeactivatePrivilegedAccount, AccountDeactivationAlreadyInProgress, Unauthorized, NotFound>> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater)
{
Expand Down
9 changes: 9 additions & 0 deletions API/Services/Account/IAccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ public interface IAccountService
/// <returns></returns>
Task<bool> TryActivateAccountAsync(string token, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
/// <param name="email">The email address of the account to resend the activation email for.</param>
/// <param name="cancellationToken"></param>
Task ResendActivationEmailAsync(string email, CancellationToken cancellationToken = default);

public Task<OneOf<Success, CannotDeactivatePrivilegedAccount, AccountDeactivationAlreadyInProgress, Unauthorized, NotFound>> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater = true);

public Task<OneOf<Success, Unauthorized, NotFound>> ReactivateAccountAsync(Guid executingUserId, Guid userId);
Expand Down
Loading