diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 7aa6a2f4..db5c3053 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -2,8 +2,13 @@ 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; namespace OpenShock.API.IntegrationTests.Tests; @@ -48,6 +53,263 @@ public async Task V2Signup_EnqueuesActivationOutbox() await Assert.That(row.Payload[EmailOutboxPayloadKeys.UserId]).IsEqualTo(user.Id.ToString()); } + // --- 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(); + 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] + 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. + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + + 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(); + var db = scope.ServiceProvider.GetRequiredService(); + + // The missing activation request was created, and a fresh activation email was enqueued. + var hasRequest = await db.UserActivationRequests.AsNoTracking().AnyAsync(r => r.UserId == userId); + await Assert.That(hasRequest).IsTrue(); + + var row = await db.EmailOutbox.AsNoTracking().SingleAsync(m => m.Recipient == email); + await Assert.That(row.Type).IsEqualTo(EmailType.AccountActivation); + await Assert.That(row.Status).IsEqualTo(EmailStatus.Pending); + await Assert.That(row.CoalesceKey).IsEqualTo(EmailOutboxCoalesceKeys.AccountActivation(userId)); + } + + [Test] + public async Task ResendActivation_WithSignupCookie_EnqueuesAnotherActivationOutbox() + { + var email = TestHelper.UniqueEmail("mail-resend-rotate"); + var username = TestHelper.UniqueUsername("mailresendrotate"); + using var client = CreateCookielessClient(); + + // 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 PostResendAsync(client, cookie); + await Assert.That(resendResponse.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); + + var rows = await db.EmailOutbox.AsNoTracking().Where(m => m.Recipient == email).ToListAsync(); + await Assert.That(rows.Count).IsEqualTo(2); + await Assert.That(rows.All(r => r.Type == EmailType.AccountActivation)).IsTrue(); + await Assert.That(rows.All(r => r.CoalesceKey == EmailOutboxCoalesceKeys.AccountActivation(user.Id))).IsTrue(); + } + + [Test] + public async Task ResendActivation_WithinCooldown_Returns429_AndEnqueuesNothing() + { + var email = TestHelper.UniqueEmail("mail-resend-cooldown"); + var username = TestHelper.UniqueUsername("mailresendcooldown"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + var cookie = MintResendCookie(userId); + + 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_Returns429() + { + var email = TestHelper.UniqueEmail("mail-resend-cap"); + var username = TestHelper.UniqueUsername("mailresendcap"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "SecurePassword123#", activated: false); + var cookie = MintResendCookie(userId); + + using var client = CreateCookielessClient(); + for (var i = 0; i < 5; i++) + { + 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_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] + 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() + { + 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 HttpClient CreateCookielessClient() => WebApplicationFactory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + 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" + })); + + private string MintResendCookie(Guid userId) + { + var provider = WebApplicationFactory.Services.GetRequiredService(); + return $"{AuthConstants.ActivationResendCookieName}={ActivationResendCookie.CreateToken(provider, userId)}"; + } + + private static Task PostResendAsync(HttpClient client, string? cookie) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/1/account/activate/resend"); + if (cookie is not null) request.Headers.Add("Cookie", cookie); + return client.SendAsync(request); + } + + // 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(); + 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)); + } + // --- Password Reset --- [Test] @@ -98,6 +360,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/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 4fbe8bc8..753ceb88 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -52,6 +52,23 @@ 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) + { + 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"); + } + } + + return getAccountResult switch { Results.NotFound => Problem(LoginError.InvalidCredentials), 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/Controller/Account/ResendActivation.cs b/API/Controller/Account/ResendActivation.cs new file mode 100644 index 00000000..c192e083 --- /dev/null +++ b/API/Controller/Account/ResendActivation.cs @@ -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 +{ + /// + /// Resend the account activation email for the account this browser just signed up + /// + /// + /// 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 + /// No pending activation for this browser (missing/expired cookie, or already activated) + /// An activation email was sent too recently + [HttpPost("activate/resend")] + [EnableRateLimiting("auth")] + [MapToApiVersion("1")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // AccountActivationResendUnavailable + [ProducesResponseType(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // AccountActivationResendTooMany + 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); + + // 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() + }; + } +} 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/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 93e38917..4c91b40e 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -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"); + } + // Direct sign-in var domain = GetCurrentCookieDomain(); if (string.IsNullOrEmpty(domain)) 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/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 a55ca174..f4142ff4 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -280,6 +280,67 @@ public async Task TryActivateAccountAsync(string secret, CancellationToken return true; } + /// + public async Task> 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); + } + + /// + 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(); + + // 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(); + } + /// public async Task> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater, string? reason = null, CancellationToken cancellationToken = default) { @@ -486,7 +547,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(); @@ -500,7 +561,8 @@ public async Task= 3) return new TooManyPasswordResets(); @@ -523,7 +585,7 @@ public async Task - public async Task> CompletePasswordResetFlowAsync(Guid passwordResetId, + public async Task> CompletePasswordResetFlowAsync(Guid passwordResetId, string secret, string newPassword, CancellationToken cancellationToken) { @@ -533,14 +595,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); @@ -551,16 +611,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 bb9909ba..3e19c853 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -47,6 +47,25 @@ public interface IAccountService /// Task TryActivateAccountAsync(string token, CancellationToken cancellationToken = default); + /// + /// 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. + /// + /// + /// + 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); public Task> ReactivateAccountAsync(Guid executingUserId, Guid userId, string? reason = null, CancellationToken cancellationToken = default); @@ -77,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 @@ -171,6 +191,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/Authentication/ActivationResendCookie.cs b/Common/Authentication/ActivationResendCookie.cs new file mode 100644 index 00000000..5dd106da --- /dev/null +++ b/Common/Authentication/ActivationResendCookie.cs @@ -0,0 +1,82 @@ +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; + +/// +/// 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 +{ + 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 = false, + 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 2f15f146..40753e1d 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); + List claims = [ with(capacity: 2 + user.Roles.Count), 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/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); diff --git a/Common/Errors/AccountError.cs b/Common/Errors/AccountError.cs index b6051d51..25bcd8cf 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", "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); 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]