Compare commits

2 Commits

Author SHA1 Message Date
Aaron Po
9238036042 Add resend confirmation email feature (#166) 2026-03-07 23:03:31 -05:00
Aaron Po
431e11e052 Add WEBSITE_BASE_URL environment variable and update email confirmation link (#165) 2026-03-07 20:11:50 -05:00
18 changed files with 598 additions and 129 deletions

View File

@@ -94,6 +94,7 @@ services:
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
restart: unless-stopped restart: unless-stopped
networks: networks:
- devnet - devnet

View File

@@ -69,6 +69,7 @@ services:
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
restart: unless-stopped restart: unless-stopped
networks: networks:
- prodnet - prodnet

View File

@@ -88,6 +88,7 @@ services:
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
volumes: volumes:
- ./test-results:/app/test-results - ./test-results:/app/test-results
restart: "no" restart: "no"

View File

@@ -71,6 +71,9 @@ REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh t
# Confirmation token secret (30-minute tokens) # Confirmation token secret (30-minute tokens)
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens
# Website base URL (used in confirmation emails)
WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
``` ```
**Security Requirements**: **Security Requirements**:
@@ -292,6 +295,7 @@ touch .env.local
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token secret | | `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token secret |
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token secret | | `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token secret |
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token secret | | `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token secret |
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
| **Authentication (Frontend)** | | **Authentication (Frontend)** |
| `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation | | `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation |
| `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset | | `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset |
@@ -359,6 +363,7 @@ DB_PASSWORD=Dev_Password_123!
ACCESS_TOKEN_SECRET=<generated-with-openssl> ACCESS_TOKEN_SECRET=<generated-with-openssl>
REFRESH_TOKEN_SECRET=<generated-with-openssl> REFRESH_TOKEN_SECRET=<generated-with-openssl>
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl> CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
WEBSITE_BASE_URL=http://localhost:3000
# Migration # Migration
CLEAR_DATABASE=true CLEAR_DATABASE=true

View File

@@ -1,6 +1,7 @@
using API.Core.Contracts.Auth; using API.Core.Contracts.Auth;
using API.Core.Contracts.Common; using API.Core.Contracts.Common;
using Domain.Entities; using Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Service.Auth; using Service.Auth;
@@ -8,6 +9,7 @@ namespace API.Core.Controllers
{ {
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController( public class AuthController(
IRegisterService registerService, IRegisterService registerService,
ILoginService loginService, ILoginService loginService,
@@ -15,6 +17,7 @@ namespace API.Core.Controllers
ITokenService tokenService ITokenService tokenService
) : ControllerBase ) : ControllerBase
{ {
[AllowAnonymous]
[HttpPost("register")] [HttpPost("register")]
public async Task<ActionResult<UserAccount>> Register( public async Task<ActionResult<UserAccount>> Register(
[FromBody] RegisterRequest req [FromBody] RegisterRequest req
@@ -47,6 +50,7 @@ namespace API.Core.Controllers
return Created("/", response); return Created("/", response);
} }
[AllowAnonymous]
[HttpPost("login")] [HttpPost("login")]
public async Task<ActionResult> Login([FromBody] LoginRequest req) public async Task<ActionResult> Login([FromBody] LoginRequest req)
{ {
@@ -82,6 +86,7 @@ namespace API.Core.Controllers
); );
} }
[AllowAnonymous]
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<ActionResult> Refresh( public async Task<ActionResult> Refresh(
[FromBody] RefreshTokenRequest req [FromBody] RefreshTokenRequest req

View File

@@ -6,6 +6,7 @@ Feature: User Account Confirmation
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the valid token When I submit a confirmation request with the valid token
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed" And the response JSON should have "message" containing "is confirmed"
@@ -14,6 +15,7 @@ Feature: User Account Confirmation
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the valid token When I submit a confirmation request with the valid token
And I submit the same confirmation request again And I submit the same confirmation request again
Then the response has HTTP status 200 Then the response has HTTP status 200
@@ -21,6 +23,8 @@ Feature: User Account Confirmation
Scenario: Confirmation fails with invalid token Scenario: Confirmation fails with invalid token
Given the API is running Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with an invalid token When I submit a confirmation request with an invalid token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
@@ -29,6 +33,7 @@ Feature: User Account Confirmation
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have an expired confirmation token for my account And I have an expired confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the expired token When I submit a confirmation request with the expired token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
@@ -37,12 +42,15 @@ Feature: User Account Confirmation
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a confirmation token signed with the wrong secret And I have a confirmation token signed with the wrong secret
And I have a valid access token for my account
When I submit a confirmation request with the tampered token When I submit a confirmation request with the tampered token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails when token is missing Scenario: Confirmation fails when token is missing
Given the API is running Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a missing token When I submit a confirmation request with a missing token
Then the response has HTTP status 400 Then the response has HTTP status 400
@@ -54,6 +62,15 @@ Feature: User Account Confirmation
Scenario: Confirmation fails with malformed token Scenario: Confirmation fails with malformed token
Given the API is running Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a malformed token When I submit a confirmation request with a malformed token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails without an access token
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
When I submit a confirmation request with the valid token without an access token
Then the response has HTTP status 401

View File

@@ -0,0 +1,36 @@
Feature: Resend Confirmation Email
As a user who did not receive the confirmation email
I want to request a resend of the confirmation email
So that I can obtain a working confirmation link while preventing abuse
Scenario: Legitimate resend for an unconfirmed user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for an already confirmed user
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
And I have a valid access token for my account
And I have confirmed my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for a non-existent user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for a non-existent user
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend requires authentication
Given the API is running
And I have registered a new account
When I submit a resend confirmation request without an access token
Then the response has HTTP status 401

View File

@@ -7,6 +7,8 @@ public class MockEmailService : IEmailService
{ {
public List<RegistrationEmail> SentRegistrationEmails { get; } = new(); public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
public Task SendRegistrationEmailAsync( public Task SendRegistrationEmailAsync(
UserAccount createdUser, UserAccount createdUser,
string confirmationToken string confirmationToken
@@ -24,9 +26,27 @@ public class MockEmailService : IEmailService
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task SendResendConfirmationEmailAsync(
UserAccount user,
string confirmationToken
)
{
SentResendConfirmationEmails.Add(
new ResendConfirmationEmail
{
UserAccount = user,
ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow,
}
);
return Task.CompletedTask;
}
public void Clear() public void Clear()
{ {
SentRegistrationEmails.Clear(); SentRegistrationEmails.Clear();
SentResendConfirmationEmails.Clear();
} }
public class RegistrationEmail public class RegistrationEmail
@@ -35,4 +55,11 @@ public class MockEmailService : IEmailService
public string ConfirmationToken { get; init; } = string.Empty; public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; } public DateTime SentAt { get; init; }
} }
public class ResendConfirmationEmail
{
public UserAccount UserAccount { get; init; } = null!;
public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; }
}
} }

View File

@@ -457,6 +457,32 @@ public class AuthSteps(ScenarioContext scenario)
await GivenIAmLoggedIn(); await GivenIAmLoggedIn();
} }
[Given("I have a valid access token for my account")]
public void GivenIHaveAValidAccessTokenForMyAccount()
{
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException(
"registered user ID not found in scenario"
);
var username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out var user
)
? user
: throw new InvalidOperationException(
"registered username not found in scenario"
);
var secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET");
scenario["accessToken"] = GenerateJwtToken(
userId,
username,
secret,
DateTime.UtcNow.AddMinutes(60)
);
}
[Given("I have a valid confirmation token for my account")] [Given("I have a valid confirmation token for my account")]
public void GivenIHaveAValidConfirmationTokenForMyAccount() public void GivenIHaveAValidConfirmationTokenForMyAccount()
{ {
@@ -587,11 +613,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t) var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t ? t
: "valid-token"; : "valid-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
); );
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -606,11 +637,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t) var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t ? t
: "valid-token"; : "valid-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
); );
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -623,11 +659,16 @@ public class AuthSteps(ScenarioContext scenario)
{ {
var client = GetClient(); var client = GetClient();
const string token = "malformed-token-not-jwt"; const string token = "malformed-token-not-jwt";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
); );
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -883,11 +924,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t) var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t ? t
: "expired-confirmation-token"; : "expired-confirmation-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
); );
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -902,11 +948,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t) var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t ? t
: "tampered-confirmation-token"; : "tampered-confirmation-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
); );
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -918,7 +969,13 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitAConfirmationRequestWithAMissingToken() public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
{ {
var client = GetClient(); var client = GetClient();
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm"); var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm");
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -974,6 +1031,30 @@ public class AuthSteps(ScenarioContext scenario)
{ {
var client = GetClient(); var client = GetClient();
const string token = "invalid-confirmation-token"; const string token = "invalid-confirmation-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the valid token without an access token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-token";
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
@@ -1043,4 +1124,91 @@ public class AuthSteps(ScenarioContext scenario)
refreshToken.Should().NotBe(previousRefreshToken); refreshToken.Should().NotBe(previousRefreshToken);
} }
} }
[Given("I have confirmed my account")]
public async Task GivenIHaveConfirmedMyAccount()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: throw new InvalidOperationException("confirmation token not found");
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
response.EnsureSuccessStatusCode();
}
[When("I submit a resend confirmation request for my account")]
public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
{
var client = GetClient();
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException("registered user ID not found");
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm/resend?userId={userId}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a resend confirmation request for a non-existent user")]
public async Task WhenISubmitAResendConfirmationRequestForANonExistentUser()
{
var client = GetClient();
var fakeUserId = Guid.NewGuid();
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm/resend?userId={fakeUserId}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a resend confirmation request without an access token")]
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
{
var client = GetClient();
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: Guid.NewGuid();
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm/resend?userId={userId}"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
} }

View File

@@ -0,0 +1,117 @@
@using Infrastructure.Email.Templates.Components
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<title>Resend Confirmation - The Biergarten App</title>
<!--[if mso]>
<style>
* { font-family: Arial, sans-serif !important; }
table { border-collapse: collapse; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<style>
* {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
</style>
<!--<![endif]-->
</head>
<body style="margin:0; padding:0; background-color:#f4f4f4; width:100%;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#f4f4f4;">
<tr>
<td align="center" style="padding:40px 10px;">
<!--[if mso]>
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width:600px;">
<tr><td>
<![endif]-->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
style="max-width:600px; background:#ffffff; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.08);">
<Header />
<tr>
<td style="padding:40px 40px 16px 40px; text-align:center;">
<h1 style="margin:0; color:#333333; font-size:26px; font-weight:700;">
New Confirmation Link
</h1>
</td>
</tr>
<tr>
<td style="padding:0 40px 20px 40px; text-align:center;">
<p style="margin:0; color:#666666; font-size:16px; line-height:24px;">
Hi <strong style="color:#333333;">@Username</strong>, you requested another email confirmation
link.
Use the button below to verify your account.
</p>
</td>
</tr>
<tr>
<td style="padding:8px 40px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:260px;"
arcsize="10%" stroke="f" fillcolor="#f59e0b">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:700;">
Confirm Email Again
</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="@ConfirmationLink" target="_blank" rel="noopener noreferrer"
style="display:inline-block; padding:16px 40px; background:#d97706; color:#ffffff; text-decoration:none; border-radius:6px; font-size:16px; font-weight:700;">
Confirm Email Again
</a>
<!--<![endif]-->
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 40px 8px 40px; text-align:center;">
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
This replacement link expires in 24 hours.
</p>
</td>
</tr>
<tr>
<td style="padding:0 40px 28px 40px; text-align:center;">
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
If you did not request this, you can safely ignore this email.
</p>
</td>
</tr>
<EmailFooter FooterText="Cheers, The Biergarten App Team" />
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</table>
</body>
</html>
@code {
[Parameter]
public string Username { get; set; } = string.Empty;
[Parameter]
public string ConfirmationLink { get; set; } = string.Empty;
}

View File

@@ -30,6 +30,23 @@ public class EmailTemplateProvider(
return await RenderComponentAsync<UserRegistration>(parameters); return await RenderComponentAsync<UserRegistration>(parameters);
} }
/// <summary>
/// Renders the ResendConfirmation template with the specified parameters.
/// </summary>
public async Task<string> RenderResendConfirmationEmailAsync(
string username,
string confirmationLink
)
{
var parameters = new Dictionary<string, object?>
{
{ nameof(ResendConfirmation.Username), username },
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink },
};
return await RenderComponentAsync<ResendConfirmation>(parameters);
}
/// <summary> /// <summary>
/// Generic method to render any Razor component to HTML. /// Generic method to render any Razor component to HTML.
/// </summary> /// </summary>

View File

@@ -15,4 +15,15 @@ public interface IEmailTemplateProvider
string username, string username,
string confirmationLink string confirmationLink
); );
/// <summary>
/// Renders the ResendConfirmation template with the specified parameters.
/// </summary>
/// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The new confirmation link</param>
/// <returns>The rendered HTML string</returns>
Task<string> RenderResendConfirmationEmailAsync(
string username,
string confirmationLink
);
} }

View File

@@ -159,7 +159,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await GetUserByIdAsync(userAccountId); return await GetUserByIdAsync(userAccountId);
} }
private async Task<bool> IsUserVerifiedAsync(Guid userAccountId) public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{ {
await using var connection = await CreateConnection(); await using var connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();

View File

@@ -75,4 +75,11 @@ public interface IAuthRepository
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId); Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary>
/// Checks whether a user account has been verified.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
} }

View File

@@ -5,151 +5,155 @@ using Domain.Exceptions;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Moq; using Moq;
using Service.Emails;
namespace Service.Auth.Tests; namespace Service.Auth.Tests;
public class ConfirmationServiceTest public class ConfirmationServiceTest
{ {
private readonly Mock<IAuthRepository> _authRepositoryMock; private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenService> _tokenServiceMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly ConfirmationService _confirmationService; private readonly Mock<IEmailService> _emailServiceMock;
private readonly ConfirmationService _confirmationService;
public ConfirmationServiceTest() public ConfirmationServiceTest()
{ {
_authRepositoryMock = new Mock<IAuthRepository>(); _authRepositoryMock = new Mock<IAuthRepository>();
_tokenServiceMock = new Mock<ITokenService>(); _tokenServiceMock = new Mock<ITokenService>();
_emailServiceMock = new Mock<IEmailService>();
_confirmationService = new ConfirmationService( _confirmationService = new ConfirmationService(
_authRepositoryMock.Object, _authRepositoryMock.Object,
_tokenServiceMock.Object _tokenServiceMock.Object,
); _emailServiceMock.Object
} );
}
[Fact] [Fact]
public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser() public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string confirmationToken = "valid-confirmation-token"; const string confirmationToken = "valid-confirmation-token";
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity); var principal = new ClaimsPrincipal(claimsIdentity);
var validatedToken = new ValidatedToken(userId, username, principal); var validatedToken = new ValidatedToken(userId, username, principal);
var userAccount = new UserAccount var userAccount = new UserAccount
{ {
UserAccountId = userId, UserAccountId = userId,
Username = username, Username = username,
FirstName = "Test", FirstName = "Test",
LastName = "User", LastName = "User",
Email = "test@example.com", Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1), DateOfBirth = new DateTime(1990, 1, 1),
}; };
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken); .ReturnsAsync(validatedToken);
_authRepositoryMock _authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId)) .Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync(userAccount); .ReturnsAsync(userAccount);
// Act // Act
var result = var result =
await _confirmationService.ConfirmUserAsync(confirmationToken); await _confirmationService.ConfirmUserAsync(confirmationToken);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserId.Should().Be(userId); result.UserId.Should().Be(userId);
result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
_tokenServiceMock.Verify( _tokenServiceMock.Verify(
x => x.ValidateConfirmationTokenAsync(confirmationToken), x => x.ValidateConfirmationTokenAsync(confirmationToken),
Times.Once Times.Once
); );
_authRepositoryMock.Verify( _authRepositoryMock.Verify(
x => x.ConfirmUserAccountAsync(userId), x => x.ConfirmUserAccountAsync(userId),
Times.Once Times.Once
); );
} }
[Fact] [Fact]
public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException() public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string invalidToken = "invalid-confirmation-token"; const string invalidToken = "invalid-confirmation-token";
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken)) .Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
.ThrowsAsync(new UnauthorizedException( .ThrowsAsync(new UnauthorizedException(
"Invalid confirmation token" "Invalid confirmation token"
)); ));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(invalidToken) await _confirmationService.ConfirmUserAsync(invalidToken)
).Should().ThrowAsync<UnauthorizedException>(); ).Should().ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException() public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
const string expiredToken = "expired-confirmation-token"; const string expiredToken = "expired-confirmation-token";
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(expiredToken)) .Setup(x => x.ValidateConfirmationTokenAsync(expiredToken))
.ThrowsAsync(new UnauthorizedException( .ThrowsAsync(new UnauthorizedException(
"Confirmation token has expired" "Confirmation token has expired"
)); ));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(expiredToken) await _confirmationService.ConfirmUserAsync(expiredToken)
).Should().ThrowAsync<UnauthorizedException>(); ).Should().ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException() public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
const string username = "nonexistent"; const string username = "nonexistent";
const string confirmationToken = "valid-token-for-nonexistent-user"; const string confirmationToken = "valid-token-for-nonexistent-user";
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity); var principal = new ClaimsPrincipal(claimsIdentity);
var validatedToken = new ValidatedToken(userId, username, principal); var validatedToken = new ValidatedToken(userId, username, principal);
_tokenServiceMock _tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken); .ReturnsAsync(validatedToken);
_authRepositoryMock _authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId)) .Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync((UserAccount?)null); .ReturnsAsync((UserAccount?)null);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(confirmationToken) await _confirmationService.ConfirmUserAsync(confirmationToken)
).Should().ThrowAsync<UnauthorizedException>() ).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*"); .WithMessage("*User account not found*");
} }
} }

View File

@@ -1,34 +1,53 @@
using Domain.Exceptions; using Domain.Exceptions;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Service.Emails;
namespace Service.Auth; namespace Service.Auth;
public class ConfirmationService( public class ConfirmationService(
IAuthRepository authRepository, IAuthRepository authRepository,
ITokenService tokenService ITokenService tokenService,
IEmailService emailService
) : IConfirmationService ) : IConfirmationService
{ {
public async Task<ConfirmationServiceReturn> ConfirmUserAsync( public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
string confirmationToken string confirmationToken
) )
{ {
var validatedToken = await tokenService.ValidateConfirmationTokenAsync( var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
confirmationToken confirmationToken
); );
var user = await authRepository.ConfirmUserAccountAsync( var user = await authRepository.ConfirmUserAccountAsync(
validatedToken.UserId validatedToken.UserId
); );
if (user == null) if (user == null)
{ {
throw new UnauthorizedException("User account not found"); throw new UnauthorizedException("User account not found");
} }
return new ConfirmationServiceReturn( return new ConfirmationServiceReturn(
DateTime.UtcNow, DateTime.UtcNow,
user.UserAccountId user.UserAccountId
); );
} }
public async Task ResendConfirmationEmailAsync(Guid userId)
{
var user = await authRepository.GetUserByIdAsync(userId);
if (user == null)
{
return; // Silent return to prevent user enumeration
}
if (await authRepository.IsUserVerifiedAsync(userId))
{
return; // Already confirmed, no-op
}
var confirmationToken = tokenService.GenerateConfirmationToken(user);
await emailService.SendResendConfirmationEmailAsync(user, confirmationToken);
}
} }

View File

@@ -8,4 +8,6 @@ public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
public interface IConfirmationService public interface IConfirmationService
{ {
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken); Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
Task ResendConfirmationEmailAsync(Guid userId);
} }

View File

@@ -10,6 +10,11 @@ public interface IEmailService
UserAccount createdUser, UserAccount createdUser,
string confirmationToken string confirmationToken
); );
public Task SendResendConfirmationEmailAsync(
UserAccount user,
string confirmationToken
);
} }
public class EmailService( public class EmailService(
@@ -17,13 +22,17 @@ public class EmailService(
IEmailTemplateProvider emailTemplateProvider IEmailTemplateProvider emailTemplateProvider
) : IEmailService ) : IEmailService
{ {
private static readonly string WebsiteBaseUrl =
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
public async Task SendRegistrationEmailAsync( public async Task SendRegistrationEmailAsync(
UserAccount createdUser, UserAccount createdUser,
string confirmationToken string confirmationToken
) )
{ {
var confirmationLink = var confirmationLink =
$"https://thebiergarten.app/confirm?token={confirmationToken}"; $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml = var emailHtml =
await emailTemplateProvider.RenderUserRegisteredEmailAsync( await emailTemplateProvider.RenderUserRegisteredEmailAsync(
@@ -38,4 +47,26 @@ public class EmailService(
isHtml: true isHtml: true
); );
} }
public async Task SendResendConfirmationEmailAsync(
UserAccount user,
string confirmationToken
)
{
var confirmationLink =
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml =
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
user.FirstName,
confirmationLink
);
await emailProvider.SendAsync(
user.Email,
"Confirm Your Email - The Biergarten App",
emailHtml,
isHtml: true
);
}
} }