From c20be03f892c7a12b1733ce54618440b62c4ec6a Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 28 Feb 2026 23:18:59 -0500 Subject: [PATCH] feat: add token validation to repository and confirmation service --- .../Features/AccessTokenValidation.feature | 51 ++++++ .../API.Specs/Features/Confirmation.feature | 51 ++++++ .../API.Specs/Features/TokenRefresh.feature | 39 +++++ .../Auth/AuthRepository.cs | 39 +++++ .../Auth/IAuthRepository.cs | 15 ++ .../ConfirmationService.test.cs | 155 ++++++++++++++++++ .../Service.Auth/IConfirmationService.cs | 29 +++- 7 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 src/Core/API/API.Specs/Features/AccessTokenValidation.feature create mode 100644 src/Core/API/API.Specs/Features/Confirmation.feature create mode 100644 src/Core/API/API.Specs/Features/TokenRefresh.feature create mode 100644 src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs diff --git a/src/Core/API/API.Specs/Features/AccessTokenValidation.feature b/src/Core/API/API.Specs/Features/AccessTokenValidation.feature new file mode 100644 index 0000000..a153930 --- /dev/null +++ b/src/Core/API/API.Specs/Features/AccessTokenValidation.feature @@ -0,0 +1,51 @@ +Feature: Protected Endpoint Access Token Validation + As a backend developer + I want protected endpoints to validate access tokens + So that unauthorized requests are rejected + + Scenario: Protected endpoint accepts valid access token + Given the API is running + And I have an existing account + And I am logged in + When I submit a request to a protected endpoint with a valid access token + Then the response has HTTP status 200 + + Scenario: Protected endpoint rejects missing access token + Given the API is running + When I submit a request to a protected endpoint without an access token + Then the response has HTTP status 401 + + Scenario: Protected endpoint rejects invalid access token + Given the API is running + When I submit a request to a protected endpoint with an invalid access token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Invalid" + + Scenario: Protected endpoint rejects expired access token + Given the API is running + And I have an existing account + And I am logged in with an immediately-expiring access token + When I submit a request to a protected endpoint with the expired token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "expired" + + Scenario: Protected endpoint rejects token signed with wrong secret + Given the API is running + And I have an access token signed with the wrong secret + When I submit a request to a protected endpoint with the tampered token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Invalid" + + Scenario: Protected endpoint rejects refresh token as access token + Given the API is running + And I have an existing account + And I am logged in + When I submit a request to a protected endpoint with my refresh token instead of access token + Then the response has HTTP status 401 + + Scenario: Protected endpoint rejects confirmation token as access token + Given the API is running + And I have registered a new account + And I have a valid confirmation token + When I submit a request to a protected endpoint with my confirmation token instead of access token + Then the response has HTTP status 401 diff --git a/src/Core/API/API.Specs/Features/Confirmation.feature b/src/Core/API/API.Specs/Features/Confirmation.feature new file mode 100644 index 0000000..ed543c3 --- /dev/null +++ b/src/Core/API/API.Specs/Features/Confirmation.feature @@ -0,0 +1,51 @@ +Feature: User Account Confirmation + As a newly registered user + I want to confirm my email address via a validation token + So that my account is fully activated + + Scenario: Successful confirmation with valid 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 + Then the response has HTTP status 200 + And the response JSON should have "message" containing "confirmed" + + Scenario: Confirmation fails with invalid token + Given the API is running + 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" + + Scenario: Confirmation fails with expired token + Given the API is running + And I have registered a new account + And I have an expired confirmation 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 "expired" + + Scenario: Confirmation fails with tampered token (wrong secret) + Given the API is running + And I have registered a new account + And I have a confirmation token signed with the wrong secret + 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" + + Scenario: Confirmation fails when token is missing + Given the API is running + When I submit a confirmation request with a missing token + Then the response has HTTP status 400 + + Scenario: Confirmation endpoint only accepts POST requests + Given the API is running + And I have a valid confirmation token + When I submit a confirmation request using an invalid HTTP method + Then the response has HTTP status 404 + + Scenario: Confirmation fails with malformed token + Given the API is running + 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" diff --git a/src/Core/API/API.Specs/Features/TokenRefresh.feature b/src/Core/API/API.Specs/Features/TokenRefresh.feature new file mode 100644 index 0000000..2cd1b98 --- /dev/null +++ b/src/Core/API/API.Specs/Features/TokenRefresh.feature @@ -0,0 +1,39 @@ +Feature: Token Refresh + As an authenticated user + I want to refresh my access token using my refresh token + So that I can maintain my session without logging in again + + Scenario: Successful token refresh with valid refresh token + Given the API is running + And I have an existing account + And I am logged in + When I submit a refresh token request with a valid refresh token + Then the response has HTTP status 200 + And the response JSON should have "message" equal "Token refreshed successfully." + And the response JSON should have a new access token + And the response JSON should have a new refresh token + + Scenario: Token refresh fails with invalid refresh token + Given the API is running + When I submit a refresh token request with an invalid refresh token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "Invalid" + + Scenario: Token refresh fails with expired refresh token + Given the API is running + And I have an existing account + And I am logged in with an immediately-expiring refresh token + When I submit a refresh token request with the expired refresh token + Then the response has HTTP status 401 + And the response JSON should have "message" containing "expired" + + Scenario: Token refresh fails when refresh token is missing + Given the API is running + When I submit a refresh token request with a missing refresh token + Then the response has HTTP status 400 + + Scenario: Token refresh endpoint only accepts POST requests + Given the API is running + And I have a valid refresh token + When I submit a refresh token request using a GET request + Then the response has HTTP status 404 diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs index 83b26d7..5864684 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs @@ -107,6 +107,45 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) await command.ExecuteNonQueryAsync(); } + public async Task GetUserByIdAsync( + Guid userAccountId + ) + { + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "usp_GetUserAccountById"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", userAccountId); + + await using var reader = await command.ExecuteReaderAsync(); + return await reader.ReadAsync() ? MapToEntity(reader) : null; + } + + public async Task ConfirmUserAccountAsync( + Guid userAccountId + ) + { + var user = await GetUserByIdAsync(userAccountId); + if (user == null) + { + return null; + } + + await using var connection = await CreateConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = "USP_ConfirmUserAccount"; + command.CommandType = CommandType.StoredProcedure; + + AddParameter(command, "@UserAccountId", userAccountId); + + await command.ExecuteNonQueryAsync(); + + // Fetch and return the updated user + return await GetUserByIdAsync(userAccountId); + } + + /// /// Maps a data reader row to a UserAccount entity. /// diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs index f8472c1..108f5c7 100644 --- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs +++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs @@ -60,4 +60,19 @@ public interface IAuthRepository /// ID of the user account /// New hashed password Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash); + + /// + /// Marks a user account as confirmed. + /// + /// ID of the user account to confirm + /// The confirmed UserAccount entity + /// If user account not found + Task ConfirmUserAccountAsync(Guid userAccountId); + + /// + /// Retrieves a user account by ID. + /// + /// ID of the user account + /// UserAccount if found, null otherwise + Task GetUserByIdAsync(Guid userAccountId); } diff --git a/src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs b/src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs new file mode 100644 index 0000000..c88cf45 --- /dev/null +++ b/src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs @@ -0,0 +1,155 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Domain.Entities; +using Domain.Exceptions; +using FluentAssertions; +using Infrastructure.Repository.Auth; +using Moq; + +namespace Service.Auth.Tests; + +public class ConfirmationServiceTest +{ + private readonly Mock _authRepositoryMock; + private readonly Mock _tokenServiceMock; + private readonly ConfirmationService _confirmationService; + + public ConfirmationServiceTest() + { + _authRepositoryMock = new Mock(); + _tokenServiceMock = new Mock(); + + _confirmationService = new ConfirmationService( + _authRepositoryMock.Object, + _tokenServiceMock.Object + ); + } + + [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 + { + 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 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); + + _authRepositoryMock + .Setup(x => x.ConfirmUserAccountAsync(userId)) + .ReturnsAsync(userAccount); + + // 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)); + + _tokenServiceMock.Verify( + x => x.ValidateConfirmationTokenAsync(confirmationToken), + Times.Once + ); + + _authRepositoryMock.Verify( + x => x.ConfirmUserAccountAsync(userId), + Times.Once + ); + } + + [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" + )); + + // Act & Assert + await FluentActions.Invoking(async () => + await _confirmationService.ConfirmUserAsync(invalidToken) + ).Should().ThrowAsync(); + } + + [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" + )); + + // Act & Assert + await FluentActions.Invoking(async () => + await _confirmationService.ConfirmUserAsync(expiredToken) + ).Should().ThrowAsync(); + } + + [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 + { + 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 validatedToken = new ValidatedToken(userId, username, principal); + + _tokenServiceMock + .Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)) + .ReturnsAsync(validatedToken); + + _authRepositoryMock + .Setup(x => x.ConfirmUserAccountAsync(userId)) + .ReturnsAsync((UserAccount?)null); + + // Act & Assert + await FluentActions.Invoking(async () => + await _confirmationService.ConfirmUserAsync(confirmationToken) + ).Should().ThrowAsync() + .WithMessage("*User account not found*"); + } +} diff --git a/src/Core/Service/Service.Auth/IConfirmationService.cs b/src/Core/Service/Service.Auth/IConfirmationService.cs index 3f7f1d8..ffce06e 100644 --- a/src/Core/Service/Service.Auth/IConfirmationService.cs +++ b/src/Core/Service/Service.Auth/IConfirmationService.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices.JavaScript; +using Domain.Exceptions; using Infrastructure.Repository.Auth; namespace Service.Auth; @@ -10,15 +11,37 @@ public interface IConfirmationService Task ConfirmUserAsync(string confirmationToken); } -public class ConfirmationService(IAuthRepository authRepository) - : IConfirmationService +public class ConfirmationService( + IAuthRepository authRepository, + ITokenService tokenService +) : IConfirmationService { private readonly IAuthRepository _authRepository = authRepository; + private readonly ITokenService _tokenService = tokenService; public async Task ConfirmUserAsync( string confirmationToken ) { - return new ConfirmationServiceReturn(DateTime.Now, Guid.NewGuid()); + // Validate the confirmation token + var validatedToken = + await _tokenService.ValidateConfirmationTokenAsync( + confirmationToken + ); + + // Confirm the user account + var user = await _authRepository.ConfirmUserAccountAsync( + validatedToken.UserId + ); + + if (user == null) + { + throw new UnauthorizedException( + "User account not found" + ); + } + + // Return the confirmation result + return new ConfirmationServiceReturn(DateTime.UtcNow, validatedToken.UserId); } }