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}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
restart: unless-stopped
networks:
- devnet

View File

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

View File

@@ -88,6 +88,7 @@ services:
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
volumes:
- ./test-results:/app/test-results
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=<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**:
@@ -292,6 +295,7 @@ touch .env.local
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token secret |
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token secret |
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token secret |
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
| **Authentication (Frontend)** |
| `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation |
| `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset |
@@ -359,6 +363,7 @@ DB_PASSWORD=Dev_Password_123!
ACCESS_TOKEN_SECRET=<generated-with-openssl>
REFRESH_TOKEN_SECRET=<generated-with-openssl>
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
WEBSITE_BASE_URL=http://localhost:3000
# Migration
CLEAR_DATABASE=true

View File

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

View File

@@ -6,6 +6,7 @@ Feature: User Account Confirmation
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
When I submit a confirmation request with the valid token
Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed"
@@ -14,6 +15,7 @@ Feature: User Account Confirmation
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
When I submit a confirmation request with the valid token
And I submit the same confirmation request again
Then the response has HTTP status 200
@@ -21,6 +23,8 @@ Feature: User Account Confirmation
Scenario: Confirmation fails with invalid token
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
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
@@ -29,6 +33,7 @@ Feature: User Account Confirmation
Given the API is running
And I have registered a new 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
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
@@ -37,12 +42,15 @@ Feature: User Account Confirmation
Given the API is running
And I have registered a new account
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
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails when token is missing
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
Then the response has HTTP status 400
@@ -54,6 +62,15 @@ Feature: User Account Confirmation
Scenario: Confirmation fails with malformed token
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
Then the response has HTTP status 401
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<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
public Task SendRegistrationEmailAsync(
UserAccount createdUser,
string confirmationToken
@@ -24,9 +26,27 @@ public class MockEmailService : IEmailService
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()
{
SentRegistrationEmails.Clear();
SentResendConfirmationEmails.Clear();
}
public class RegistrationEmail
@@ -35,4 +55,11 @@ public class MockEmailService : IEmailService
public string ConfirmationToken { get; init; } = string.Empty;
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();
}
[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")]
public void GivenIHaveAValidConfirmationTokenForMyAccount()
{
@@ -587,11 +613,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-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();
@@ -606,11 +637,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-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();
@@ -623,11 +659,16 @@ public class AuthSteps(ScenarioContext scenario)
{
var client = GetClient();
const string token = "malformed-token-not-jwt";
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();
@@ -883,11 +924,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "expired-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();
@@ -902,11 +948,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "tampered-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();
@@ -918,7 +969,13 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
{
var client = GetClient();
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
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 responseBody = await response.Content.ReadAsStringAsync();
@@ -974,6 +1031,30 @@ public class AuthSteps(ScenarioContext scenario)
{
var client = GetClient();
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(
HttpMethod.Post,
@@ -1043,4 +1124,91 @@ public class AuthSteps(ScenarioContext scenario)
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);
}
/// <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>
/// Generic method to render any Razor component to HTML.
/// </summary>

View File

@@ -15,4 +15,15 @@ public interface IEmailTemplateProvider
string username,
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);
}
private async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();

View File

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

View File

@@ -1,34 +1,53 @@
using Domain.Exceptions;
using Infrastructure.Repository.Auth;
using Service.Emails;
namespace Service.Auth;
public class ConfirmationService(
IAuthRepository authRepository,
ITokenService tokenService
ITokenService tokenService,
IEmailService emailService
) : IConfirmationService
{
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
string confirmationToken
)
{
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
confirmationToken
);
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
string confirmationToken
)
{
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
confirmationToken
);
var user = await authRepository.ConfirmUserAccountAsync(
validatedToken.UserId
);
var user = await authRepository.ConfirmUserAccountAsync(
validatedToken.UserId
);
if (user == null)
{
throw new UnauthorizedException("User account not found");
}
if (user == null)
{
throw new UnauthorizedException("User account not found");
}
return new ConfirmationServiceReturn(
DateTime.UtcNow,
user.UserAccountId
);
}
return new ConfirmationServiceReturn(
DateTime.UtcNow,
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
{
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
Task ResendConfirmationEmailAsync(Guid userId);
}

View File

@@ -10,6 +10,11 @@ public interface IEmailService
UserAccount createdUser,
string confirmationToken
);
public Task SendResendConfirmationEmailAsync(
UserAccount user,
string confirmationToken
);
}
public class EmailService(
@@ -17,13 +22,17 @@ public class EmailService(
IEmailTemplateProvider emailTemplateProvider
) : 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(
UserAccount createdUser,
string confirmationToken
)
{
var confirmationLink =
$"https://thebiergarten.app/confirm?token={confirmationToken}";
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml =
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
@@ -38,4 +47,26 @@ public class EmailService(
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
);
}
}