mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
feat: add token validation to repository and confirmation service
This commit is contained in:
@@ -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
|
||||||
51
src/Core/API/API.Specs/Features/Confirmation.feature
Normal file
51
src/Core/API/API.Specs/Features/Confirmation.feature
Normal file
@@ -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"
|
||||||
39
src/Core/API/API.Specs/Features/TokenRefresh.feature
Normal file
39
src/Core/API/API.Specs/Features/TokenRefresh.feature
Normal file
@@ -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
|
||||||
@@ -107,6 +107,45 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Domain.Entities.UserAccount?> 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<Domain.Entities.UserAccount?> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a data reader row to a UserAccount entity.
|
/// Maps a data reader row to a UserAccount entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -60,4 +60,19 @@ public interface IAuthRepository
|
|||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <param name="newPasswordHash">New hashed password</param>
|
/// <param name="newPasswordHash">New hashed password</param>
|
||||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a user account as confirmed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||||
|
/// <returns>The confirmed UserAccount entity</returns>
|
||||||
|
/// <exception cref="UnauthorizedException">If user account not found</exception>
|
||||||
|
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
|
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||||
}
|
}
|
||||||
|
|||||||
155
src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs
Normal file
155
src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs
Normal file
@@ -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<IAuthRepository> _authRepositoryMock;
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
private readonly ConfirmationService _confirmationService;
|
||||||
|
|
||||||
|
public ConfirmationServiceTest()
|
||||||
|
{
|
||||||
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
_tokenServiceMock = new Mock<ITokenService>();
|
||||||
|
|
||||||
|
_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<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 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<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<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";
|
||||||
|
|
||||||
|
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 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<UnauthorizedException>()
|
||||||
|
.WithMessage("*User account not found*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Runtime.InteropServices.JavaScript;
|
using System.Runtime.InteropServices.JavaScript;
|
||||||
|
using Domain.Exceptions;
|
||||||
using Infrastructure.Repository.Auth;
|
using Infrastructure.Repository.Auth;
|
||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
@@ -10,15 +11,37 @@ public interface IConfirmationService
|
|||||||
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ConfirmationService(IAuthRepository authRepository)
|
public class ConfirmationService(
|
||||||
: IConfirmationService
|
IAuthRepository authRepository,
|
||||||
|
ITokenService tokenService
|
||||||
|
) : IConfirmationService
|
||||||
{
|
{
|
||||||
private readonly IAuthRepository _authRepository = authRepository;
|
private readonly IAuthRepository _authRepository = authRepository;
|
||||||
|
private readonly ITokenService _tokenService = tokenService;
|
||||||
|
|
||||||
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
||||||
string confirmationToken
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user