Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
105d509
feat: resend account activation email endpoint
hhvrc Jun 24, 2026
9e021d7
Merge develop into feature/resend-activation-email
hhvrc Jun 30, 2026
bce4c6d
Merge remote-tracking branch 'origin/develop' into feature/resend-act…
hhvrc Aug 14, 2026
a3b819b
refactor: return a plain 200 from the activation resend endpoint
hhvrc Aug 14, 2026
89def06
Merge branch 'develop' into feature/resend-activation-email
hhvrc Aug 14, 2026
2f951af
Merge branch 'develop' into feature/resend-activation-email
hhvrc Sep 30, 2026
6b16e04
fix: require turnstile on the resend activation endpoint
hhvrc Sep 30, 2026
b07a228
Merge remote-tracking branch 'origin/develop' into feature/resend-act…
hhvrc Nov 4, 2026
5e1310e
feat: resend activation email on login, rate limit resends
hhvrc Nov 4, 2026
a6ee16c
fix: serialize activation resends per user, make login resend best-ef…
hhvrc Nov 4, 2026
bddabc6
fix: hide account-dependent failures from the public activation resend
hhvrc Nov 4, 2026
6430200
feat: completing a password reset activates the account
hhvrc Nov 4, 2026
de288d8
feat: identify activation resend by a signup cookie instead of an email
hhvrc Nov 4, 2026
3cc552a
fix: make the activation resend cookie readable by the frontend
hhvrc Nov 4, 2026
cfbba98
fix: neutral wording for activation resend errors
hhvrc Nov 4, 2026
7e51c75
Merge remote-tracking branch 'origin/develop' into feature/resend-act…
hhvrc Nov 4, 2026
ba2ae50
refactor: use a switch expression for the activation resend result
hhvrc Nov 4, 2026
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
321 changes: 321 additions & 0 deletions API.IntegrationTests/Tests/MailTests.cs

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions API/Controller/Account/LoginV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ public async Task<IActionResult> 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)
{
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");
}
}


Comment thread
hhvrc marked this conversation as resolved.
Resolved
return getAccountResult switch
{
Results.NotFound => Problem(LoginError.InvalidCredentials),
Expand Down
1 change: 0 additions & 1 deletion API/Controller/Account/PasswordResetComplete.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ public async Task<IActionResult> 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()
Expand Down
56 changes: 56 additions & 0 deletions API/Controller/Account/ResendActivation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Diagnostics;
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using Asp.Versioning;
using Microsoft.AspNetCore.RateLimiting;
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
{
/// <summary>
/// Resend the account activation email for the account this browser just signed up
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <response code="200">Activation email enqueued</response>
/// <response code="400">No pending activation for this browser (missing/expired cookie, or already activated)</response>
/// <response code="429">An activation email was sent too recently</response>
[HttpPost("activate/resend")]
[EnableRateLimiting("auth")]
[MapToApiVersion("1")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // AccountActivationResendUnavailable
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // AccountActivationResendTooMany
public async Task<IActionResult> 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);

// 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
{
Results.Success => Ok(),
TooManyActivationResends => Problem(AccountError.AccountActivationResendTooMany),
AccountDeactivated or Results.NotFound => Problem(AccountError.AccountActivationResendUnavailable),
_ => throw new UnreachableException()
};
}
}
11 changes: 9 additions & 2 deletions API/Controller/Account/SignupV2.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -49,7 +50,7 @@ public async Task<IActionResult> 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
{
Expand All @@ -58,6 +59,12 @@ public async Task<IActionResult> 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();
}
}
26 changes: 26 additions & 0 deletions API/Controller/OAuth/HandOff.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,32 @@ 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).
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");
}
Comment thread
hhvrc marked this conversation as resolved.
Resolved

// Direct sign-in
var domain = GetCurrentCookieDomain();
if (string.IsNullOrEmpty(domain))
Expand Down
8 changes: 7 additions & 1 deletion API/Controller/OAuth/SignupFinalize.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -115,6 +116,11 @@ public async Task<IActionResult> 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);
Expand Down
6 changes: 5 additions & 1 deletion API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
Expand Down
82 changes: 75 additions & 7 deletions API/Services/Account/AccountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,67 @@ public async Task<bool> TryActivateAccountAsync(string secret, CancellationToken
return true;
}

/// <inheritdoc />
public async Task<Union4<Success, TooManyActivationResends, AccountDeactivated, NotFound>> ResendActivationEmailAsync(string usernameOrEmail, CancellationToken cancellationToken = default)
{
var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant();

var userId = await _db.Users
.Where(u => u.Email == lowercaseUsernameOrEmail || u.Name == lowercaseUsernameOrEmail)
.Select(u => (Guid?)u.Id)
.FirstOrDefaultAsync(cancellationToken);
if (userId is null) return new NotFound();

return await ResendActivationEmailAsync(userId.Value, cancellationToken);
}

/// <inheritdoc />
public async Task<Union4<Success, TooManyActivationResends, AccountDeactivated, NotFound>> 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();

// Serialize resends per user: lock the user row for the whole check-then-enqueue, so concurrent
// requests can't both pass the limit below. Released on commit/rollback.
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken);
await _db.Database.ExecuteSqlAsync($"SELECT 1 FROM users WHERE id = {userId} FOR UPDATE", cancellationToken);

var user = await _db.Users
.Include(u => u.UserActivationRequest)
.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();

// 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.
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);

if (recent.Count >= 5 || recent.Any(createdAt => createdAt >= DateTime.UtcNow - Duration.ActivationResendCooldown))
return new TooManyActivationResends();

user.UserActivationRequest ??= new UserActivationRequest
{
UserId = user.Id,
TokenHash = SeedTokenHash(),
CreatedAt = DateTime.UtcNow
};

_db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, user.Email, user.Name));
await _db.SaveChangesAsync(cancellationToken);
await tx.CommitAsync(cancellationToken);

// Notify the outbox delivery job only after a successful commit.
await NotifyEmailOutboxAsync();

return new Success();
}

/// <inheritdoc />
public async Task<Union5<Success, CannotDeactivatePrivilegedAccount, AccountDeactivationAlreadyInProgress, Unauthorized, NotFound>> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater, string? reason = null, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -486,7 +547,7 @@ public async Task<Union3<Success, NotFound, SecretInvalid>> CheckPasswordResetEx
}

/// <inheritdoc />
public async Task<Union5<Success, TooManyPasswordResets, AccountNotActivated, AccountDeactivated, NotFound>> CreatePasswordResetFlowAsync(string email, CancellationToken cancellationToken)
public async Task<Union4<Success, TooManyPasswordResets, AccountDeactivated, NotFound>> CreatePasswordResetFlowAsync(string email, CancellationToken cancellationToken)
{
var validSince = DateTime.UtcNow - Duration.PasswordResetRequestLifetime;
var lowerCaseEmail = email.ToLowerInvariant();
Expand All @@ -500,7 +561,8 @@ public async Task<Union5<Success, TooManyPasswordResets, AccountNotActivated, Ac
})
.FirstOrDefaultAsync(cancellationToken);
if (user is null) return new NotFound();
if (user.User.ActivatedAt is null) return new AccountNotActivated();
// Unactivated accounts are allowed: completing the reset proves control of the inbox, which is all
// activation checks, so it activates the account (see CompletePasswordResetFlowAsync).
if (user.IsDeactivated) return new AccountDeactivated();
if (user.PasswordResetCount >= 3) return new TooManyPasswordResets();

Expand All @@ -523,7 +585,7 @@ public async Task<Union5<Success, TooManyPasswordResets, AccountNotActivated, Ac
}

/// <inheritdoc />
public async Task<Union5<Success, NotFound, AccountNotActivated, AccountDeactivated, SecretInvalid>> CompletePasswordResetFlowAsync(Guid passwordResetId,
public async Task<Union4<Success, NotFound, AccountDeactivated, SecretInvalid>> CompletePasswordResetFlowAsync(Guid passwordResetId,
string secret, string newPassword,
CancellationToken cancellationToken)
{
Expand All @@ -533,14 +595,12 @@ public async Task<Union5<Success, NotFound, AccountNotActivated, AccountDeactiva
.Select(x => new
{
Reset = x,
UserActivatedAt = x.User.ActivatedAt,
IsDeactivated = x.User.UserDeactivation != null,
UserSecurityStamp = x.User.SecurityStamp
})
.FirstOrDefaultAsync(x => x.Reset.Id == passwordResetId && x.Reset.UsedAt == null && x.Reset.CreatedAt >= validSince
&& x.Reset.SecurityStampAtCreate == x.UserSecurityStamp, 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);
Expand All @@ -551,16 +611,24 @@ public async Task<Union5<Success, NotFound, AccountNotActivated, AccountDeactiva
// Race-safe consume + apply: only updates if SecurityStamp still matches the snapshot.
// If a sibling reset (or a separate password/email change) completed since the read above,
// the stamp has rotated and the predicate matches zero rows.
// The reset link was delivered to the account's email, so completing it also verifies that email:
// an unactivated account is activated here (an already activated one keeps its original timestamp).
var newPasswordHash = HashingUtils.HashPassword(newPassword);
var newStamp = Guid.CreateVersion7();
var now = DateTime.UtcNow;
var userRows = await _db.Users
.Where(u => u.Id == reset.Reset.UserId && u.SecurityStamp == reset.Reset.SecurityStampAtCreate)
.ExecuteUpdateAsync(s => s
.SetProperty(u => u.PasswordHash, newPasswordHash)
.SetProperty(u => u.SecurityStamp, newStamp), 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);
Expand Down
Loading
Loading