mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
Feature: Add token validation, basic confirmation workflow (#164)
This commit is contained in:
@@ -1,36 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>API.Core</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>API.Core</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.OpenApi"
|
||||
Version="9.0.11"
|
||||
/>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference
|
||||
Include="FluentValidation.AspNetCore"
|
||||
Version="11.3.0"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Infrastructure\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Infrastructure\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using API.Core.Contracts.Common;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace API.Core.Authentication;
|
||||
|
||||
public class JwtAuthenticationHandler(
|
||||
IOptionsMonitor<JwtAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
IConfiguration configuration
|
||||
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
secret = configuration["Jwt:SecretKey"];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
return AuthenticateResult.Fail("JWT secret is not configured");
|
||||
}
|
||||
|
||||
// Check if Authorization header exists
|
||||
if (
|
||||
!Request.Headers.TryGetValue(
|
||||
"Authorization",
|
||||
out var authHeaderValue
|
||||
)
|
||||
)
|
||||
{
|
||||
return AuthenticateResult.Fail("Authorization header is missing");
|
||||
}
|
||||
|
||||
var authHeader = authHeaderValue.ToString();
|
||||
if (
|
||||
!authHeader.StartsWith(
|
||||
"Bearer ",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
)
|
||||
{
|
||||
return AuthenticateResult.Fail(
|
||||
"Invalid authorization header format"
|
||||
);
|
||||
}
|
||||
|
||||
var token = authHeader.Substring("Bearer ".Length).Trim();
|
||||
|
||||
try
|
||||
{
|
||||
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
||||
token,
|
||||
secret
|
||||
);
|
||||
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return AuthenticateResult.Fail(
|
||||
$"Token validation failed: {ex.Message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
Response.ContentType = "application/json";
|
||||
Response.StatusCode = 401;
|
||||
|
||||
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
|
||||
await Response.WriteAsJsonAsync(response);
|
||||
}
|
||||
}
|
||||
|
||||
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
||||
@@ -17,3 +17,5 @@ public record RegistrationPayload(
|
||||
string AccessToken,
|
||||
bool ConfirmationEmailSent
|
||||
);
|
||||
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
|
||||
19
src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
19
src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace API.Core.Contracts.Auth;
|
||||
|
||||
public record RefreshTokenRequest
|
||||
{
|
||||
public string RefreshToken { get; init; } = default!;
|
||||
}
|
||||
|
||||
public class RefreshTokenRequestValidator
|
||||
: AbstractValidator<RefreshTokenRequest>
|
||||
{
|
||||
public RefreshTokenRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken)
|
||||
.NotEmpty()
|
||||
.WithMessage("Refresh token is required");
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,19 @@ namespace API.Core.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AuthController(IRegisterService register, ILoginService login)
|
||||
: ControllerBase
|
||||
public class AuthController(
|
||||
IRegisterService registerService,
|
||||
ILoginService loginService,
|
||||
IConfirmationService confirmationService,
|
||||
ITokenService tokenService
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpPost("register")]
|
||||
public async Task<ActionResult<UserAccount>> Register(
|
||||
[FromBody] RegisterRequest req
|
||||
)
|
||||
{
|
||||
var rtn = await register.RegisterAsync(
|
||||
var rtn = await registerService.RegisterAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = Guid.Empty,
|
||||
@@ -46,7 +50,7 @@ namespace API.Core.Controllers
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
var rtn = await login.LoginAsync(req.Username, req.Password);
|
||||
var rtn = await loginService.LoginAsync(req.Username, req.Password);
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
@@ -61,5 +65,42 @@ namespace API.Core.Controllers
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("confirm")]
|
||||
public async Task<ActionResult> Confirm([FromQuery] string token)
|
||||
{
|
||||
var rtn = await confirmationService.ConfirmUserAsync(token);
|
||||
return Ok(
|
||||
new ResponseBody<ConfirmationPayload>
|
||||
{
|
||||
Message = "User with ID " + rtn.UserId + " is confirmed.",
|
||||
Payload = new ConfirmationPayload(
|
||||
rtn.UserId,
|
||||
rtn.ConfirmedAt
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult> Refresh(
|
||||
[FromBody] RefreshTokenRequest req
|
||||
)
|
||||
{
|
||||
var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken);
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Token refreshed successfully.",
|
||||
Payload = new LoginPayload(
|
||||
rtn.UserAccount.UserAccountId,
|
||||
rtn.UserAccount.Username,
|
||||
rtn.RefreshToken,
|
||||
rtn.AccessToken
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
src/Core/API/API.Core/Controllers/ProtectedController.cs
Normal file
27
src/Core/API/API.Core/Controllers/ProtectedController.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Security.Claims;
|
||||
using API.Core.Contracts.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Core.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class ProtectedController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public ActionResult<ResponseBody<object>> Get()
|
||||
{
|
||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var username = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<object>
|
||||
{
|
||||
Message = "Protected endpoint accessed successfully",
|
||||
Payload = new { userId, username },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using API.Core.Contracts.Common;
|
||||
using Domain.Exceptions;
|
||||
using FluentValidation;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
@@ -71,6 +72,16 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case SqlException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = "A database error occurred." }
|
||||
)
|
||||
{
|
||||
StatusCode = 503,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case Domain.Exceptions.ValidationException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using API.Core;
|
||||
using API.Core.Authentication;
|
||||
using API.Core.Contracts.Common;
|
||||
using Domain.Exceptions;
|
||||
using FluentValidation;
|
||||
@@ -11,11 +12,12 @@ using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Infrastructure.Repository.UserAccount;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Service.Auth;
|
||||
using Service.UserManagement.User;
|
||||
using Service.Emails;
|
||||
using Service.UserManagement.User;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -64,10 +66,21 @@ builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
||||
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
|
||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
|
||||
|
||||
// Register the exception filter
|
||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
||||
|
||||
// Configure JWT Authentication
|
||||
builder
|
||||
.Services.AddAuthentication("JWT")
|
||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
|
||||
"JWT",
|
||||
options => { }
|
||||
);
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
@@ -76,6 +89,9 @@ app.MapOpenApi();
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Health check endpoint (used by Docker health checks and orchestrators)
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>API.Specs</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>API.Specs</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
|
||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||
<PackageReference Include="Reqnroll" Version="3.3.3" />
|
||||
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
|
||||
<PackageReference
|
||||
Include="Reqnroll.Tools.MsBuild.Generation"
|
||||
Version="3.3.3"
|
||||
PrivateAssets="all"
|
||||
/>
|
||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||
<PackageReference Include="Reqnroll" Version="3.3.3" />
|
||||
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
|
||||
<PackageReference
|
||||
Include="Reqnroll.Tools.MsBuild.Generation"
|
||||
Version="3.3.3"
|
||||
PrivateAssets="all"
|
||||
/>
|
||||
|
||||
<!-- ASP.NET Core integration testing -->
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.Mvc.Testing"
|
||||
Version="9.0.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
<!-- ASP.NET Core integration testing -->
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.Mvc.Testing"
|
||||
Version="9.0.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Ensure feature files are included in the project -->
|
||||
<None Include="Features\**\*.feature" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Ensure feature files are included in the project -->
|
||||
<None Include="Features\**\*.feature" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,8 +3,8 @@ ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||
|
||||
@@ -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 "Unauthorized"
|
||||
|
||||
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 "Unauthorized"
|
||||
|
||||
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 "Unauthorized"
|
||||
|
||||
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
|
||||
59
src/Core/API/API.Specs/Features/Confirmation.feature
Normal file
59
src/Core/API/API.Specs/Features/Confirmation.feature
Normal file
@@ -0,0 +1,59 @@
|
||||
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 "is confirmed"
|
||||
|
||||
Scenario: Re-confirming an already verified account remains successful
|
||||
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
|
||||
And I submit the same confirmation request again
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" containing "is 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 token"
|
||||
|
||||
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 "Invalid token"
|
||||
|
||||
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 token"
|
||||
|
||||
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 token"
|
||||
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 "Invalid token"
|
||||
|
||||
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
|
||||
@@ -149,4 +149,61 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
);
|
||||
value.GetString().Should().Be(expected);
|
||||
}
|
||||
|
||||
[Then("the response JSON should have {string} containing {string}")]
|
||||
public void ThenTheResponseJsonShouldHaveStringContainingString(
|
||||
string field,
|
||||
string expectedSubstring
|
||||
)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario
|
||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody!);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty(field, out var value))
|
||||
{
|
||||
root.TryGetProperty("payload", out var payloadElem)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||
field
|
||||
);
|
||||
payloadElem
|
||||
.ValueKind.Should()
|
||||
.Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem
|
||||
.TryGetProperty(field, out value)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present inside 'payload'",
|
||||
field
|
||||
);
|
||||
}
|
||||
|
||||
value
|
||||
.ValueKind.Should()
|
||||
.Be(
|
||||
JsonValueKind.String,
|
||||
"Expected field '{0}' to be a string",
|
||||
field
|
||||
);
|
||||
var actualValue = value.GetString();
|
||||
actualValue
|
||||
.Should()
|
||||
.Contain(
|
||||
expectedSubstring,
|
||||
"Expected field '{0}' to contain '{1}' but was '{2}'",
|
||||
field,
|
||||
expectedSubstring,
|
||||
actualValue
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using API.Specs;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Jwt;
|
||||
using Reqnroll;
|
||||
|
||||
namespace API.Specs.Steps;
|
||||
@@ -13,6 +14,10 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
private const string ResponseKey = "response";
|
||||
private const string ResponseBodyKey = "responseBody";
|
||||
private const string TestUserKey = "testUser";
|
||||
private const string RegisteredUserIdKey = "registeredUserId";
|
||||
private const string RegisteredUsernameKey = "registeredUsername";
|
||||
private const string PreviousAccessTokenKey = "previousAccessToken";
|
||||
private const string PreviousRefreshTokenKey = "previousRefreshToken";
|
||||
|
||||
private HttpClient GetClient()
|
||||
{
|
||||
@@ -34,6 +39,66 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
return client;
|
||||
}
|
||||
|
||||
private static string GetRequiredEnvVar(string name)
|
||||
{
|
||||
return Environment.GetEnvironmentVariable(name)
|
||||
?? throw new InvalidOperationException(
|
||||
$"{name} environment variable is not set"
|
||||
);
|
||||
}
|
||||
|
||||
private static string GenerateJwtToken(
|
||||
Guid userId,
|
||||
string username,
|
||||
string secret,
|
||||
DateTime expiry
|
||||
)
|
||||
{
|
||||
var infra = new JwtInfrastructure();
|
||||
return infra.GenerateJwt(userId, username, expiry, secret);
|
||||
}
|
||||
|
||||
private static Guid ParseRegisteredUserId(JsonElement root)
|
||||
{
|
||||
return root
|
||||
.GetProperty("payload")
|
||||
.GetProperty("userAccountId")
|
||||
.GetGuid();
|
||||
}
|
||||
|
||||
private static string ParseRegisteredUsername(JsonElement root)
|
||||
{
|
||||
return root
|
||||
.GetProperty("payload")
|
||||
.GetProperty("username")
|
||||
.GetString()
|
||||
?? throw new InvalidOperationException(
|
||||
"username missing from registration payload"
|
||||
);
|
||||
}
|
||||
|
||||
private static string ParseTokenFromPayload(
|
||||
JsonElement payload,
|
||||
string camelCaseName,
|
||||
string pascalCaseName
|
||||
)
|
||||
{
|
||||
if (
|
||||
payload.TryGetProperty(camelCaseName, out var tokenElem)
|
||||
|| payload.TryGetProperty(pascalCaseName, out tokenElem)
|
||||
)
|
||||
{
|
||||
return tokenElem.GetString()
|
||||
?? throw new InvalidOperationException(
|
||||
$"{camelCaseName} is null"
|
||||
);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Could not find token field '{camelCaseName}' in payload"
|
||||
);
|
||||
}
|
||||
|
||||
[Given("I have an existing account")]
|
||||
public void GivenIHaveAnExistingAccount()
|
||||
{
|
||||
@@ -229,6 +294,18 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd");
|
||||
}
|
||||
|
||||
// Keep default registration fixture values unique across repeated runs.
|
||||
if (email == "newuser@example.com")
|
||||
{
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
email = $"newuser-{suffix}@example.com";
|
||||
|
||||
if (username == "newuser")
|
||||
{
|
||||
username = $"newuser-{suffix}";
|
||||
}
|
||||
}
|
||||
|
||||
var password = row["Password"];
|
||||
|
||||
var registrationData = new
|
||||
@@ -284,4 +361,686 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[Given("I have registered a new account")]
|
||||
public async Task GivenIHaveRegisteredANewAccount()
|
||||
{
|
||||
var client = GetClient();
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var registrationData = new
|
||||
{
|
||||
username = $"newuser-{suffix}",
|
||||
firstName = "New",
|
||||
lastName = "User",
|
||||
email = $"newuser-{suffix}@example.com",
|
||||
dateOfBirth = "1990-01-01",
|
||||
password = "Password1!",
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(registrationData);
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"/api/auth/register"
|
||||
)
|
||||
{
|
||||
Content = new StringContent(
|
||||
body,
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root);
|
||||
scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
|
||||
}
|
||||
|
||||
[Given("I am logged in")]
|
||||
public async Task GivenIAmLoggedIn()
|
||||
{
|
||||
var client = GetClient();
|
||||
var loginData = new { username = "test.user", password = "password" };
|
||||
var body = JsonSerializer.Serialize(loginData);
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"/api/auth/login"
|
||||
)
|
||||
{
|
||||
Content = new StringContent(
|
||||
body,
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("payload", out var payloadElem))
|
||||
{
|
||||
if (
|
||||
payloadElem.TryGetProperty("accessToken", out var tokenElem)
|
||||
|| payloadElem.TryGetProperty("AccessToken", out tokenElem)
|
||||
)
|
||||
{
|
||||
scenario["accessToken"] = tokenElem.GetString();
|
||||
}
|
||||
if (
|
||||
payloadElem.TryGetProperty("refreshToken", out var refreshElem)
|
||||
|| payloadElem.TryGetProperty("RefreshToken", out refreshElem)
|
||||
)
|
||||
{
|
||||
scenario["refreshToken"] = refreshElem.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Given("I have a valid refresh token")]
|
||||
public async Task GivenIHaveAValidRefreshToken()
|
||||
{
|
||||
await GivenIAmLoggedIn();
|
||||
}
|
||||
|
||||
[Given("I am logged in with an immediately-expiring refresh token")]
|
||||
public async Task GivenIAmLoggedInWithAnImmediatelyExpiringRefreshToken()
|
||||
{
|
||||
// For now, create a normal login; in production this would generate an expiring token
|
||||
await GivenIAmLoggedIn();
|
||||
}
|
||||
|
||||
[Given("I have a valid confirmation token for my account")]
|
||||
public void GivenIHaveAValidConfirmationTokenForMyAccount()
|
||||
{
|
||||
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("CONFIRMATION_TOKEN_SECRET");
|
||||
scenario["confirmationToken"] = GenerateJwtToken(
|
||||
userId,
|
||||
username,
|
||||
secret,
|
||||
DateTime.UtcNow.AddMinutes(5)
|
||||
);
|
||||
}
|
||||
|
||||
[Given("I have an expired confirmation token for my account")]
|
||||
public void GivenIHaveAnExpiredConfirmationTokenForMyAccount()
|
||||
{
|
||||
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("CONFIRMATION_TOKEN_SECRET");
|
||||
scenario["confirmationToken"] = GenerateJwtToken(
|
||||
userId,
|
||||
username,
|
||||
secret,
|
||||
DateTime.UtcNow.AddMinutes(-5)
|
||||
);
|
||||
}
|
||||
|
||||
[Given("I have a confirmation token signed with the wrong secret")]
|
||||
public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret()
|
||||
{
|
||||
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"
|
||||
);
|
||||
|
||||
const string wrongSecret =
|
||||
"wrong-confirmation-secret-that-is-very-long-1234567890";
|
||||
scenario["confirmationToken"] = GenerateJwtToken(
|
||||
userId,
|
||||
username,
|
||||
wrongSecret,
|
||||
DateTime.UtcNow.AddMinutes(5)
|
||||
);
|
||||
}
|
||||
|
||||
[When(
|
||||
"I submit a request to a protected endpoint with a valid access token"
|
||||
)]
|
||||
public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("accessToken", out var t)
|
||||
? t
|
||||
: "invalid-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/api/protected"
|
||||
)
|
||||
{
|
||||
Headers = { { "Authorization", $"Bearer {token}" } },
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When(
|
||||
"I submit a request to a protected endpoint with an invalid access token"
|
||||
)]
|
||||
public async Task WhenISubmitARequestToAProtectedEndpointWithAnInvalidAccessToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/api/protected"
|
||||
)
|
||||
{
|
||||
Headers = { { "Authorization", "Bearer invalid-token-format" } },
|
||||
};
|
||||
|
||||
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")]
|
||||
public async Task WhenISubmitAConfirmationRequestWithTheValidToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||
? t
|
||||
: "valid-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I submit the same confirmation request again")]
|
||||
public async Task WhenISubmitTheSameConfirmationRequestAgain()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||
? t
|
||||
: "valid-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
|
||||
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 a malformed token")]
|
||||
public async Task WhenISubmitAConfirmationRequestWithAMalformedToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
const string token = "malformed-token-not-jwt";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I submit a refresh token request with a valid refresh token")]
|
||||
public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
if (scenario.TryGetValue<string>("accessToken", out var oldAccessToken))
|
||||
{
|
||||
scenario[PreviousAccessTokenKey] = oldAccessToken;
|
||||
}
|
||||
if (scenario.TryGetValue<string>("refreshToken", out var oldRefreshToken))
|
||||
{
|
||||
scenario[PreviousRefreshTokenKey] = oldRefreshToken;
|
||||
}
|
||||
|
||||
var token = scenario.TryGetValue<string>("refreshToken", out var t)
|
||||
? t
|
||||
: "valid-refresh-token";
|
||||
var body = JsonSerializer.Serialize(new { refreshToken = token });
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"/api/auth/refresh"
|
||||
)
|
||||
{
|
||||
Content = new StringContent(
|
||||
body,
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I submit a refresh token request with an invalid refresh token")]
|
||||
public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var body = JsonSerializer.Serialize(
|
||||
new { refreshToken = "invalid-refresh-token" }
|
||||
);
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"/api/auth/refresh"
|
||||
)
|
||||
{
|
||||
Content = new StringContent(
|
||||
body,
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I submit a refresh token request with the expired refresh token")]
|
||||
public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
// Use an expired token
|
||||
var body = JsonSerializer.Serialize(
|
||||
new { refreshToken = "expired-refresh-token" }
|
||||
);
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"/api/auth/refresh"
|
||||
)
|
||||
{
|
||||
Content = new StringContent(
|
||||
body,
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I submit a refresh token request with a missing refresh token")]
|
||||
public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var body = JsonSerializer.Serialize(new { });
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
"/api/auth/refresh"
|
||||
)
|
||||
{
|
||||
Content = new StringContent(
|
||||
body,
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I submit a refresh token request using a GET request")]
|
||||
public async Task WhenISubmitARefreshTokenRequestUsingAGETRequest()
|
||||
{
|
||||
var client = GetClient();
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/api/auth/refresh"
|
||||
)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"{}",
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
// Protected Endpoint Steps
|
||||
[When("I submit a request to a protected endpoint without an access token")]
|
||||
public async Task WhenISubmitARequestToAProtectedEndpointWithoutAnAccessToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/api/protected"
|
||||
);
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[Given("I am logged in with an immediately-expiring access token")]
|
||||
public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken()
|
||||
{
|
||||
// Simulate an expired access token for auth rejection behavior.
|
||||
scenario["accessToken"] = "expired-access-token";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Given("I have an access token signed with the wrong secret")]
|
||||
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
|
||||
{
|
||||
// Create a token with a different secret
|
||||
scenario["accessToken"] =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
||||
}
|
||||
|
||||
[When("I submit a request to a protected endpoint with the expired token")]
|
||||
public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("accessToken", out var t)
|
||||
? t
|
||||
: "expired-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/api/protected"
|
||||
)
|
||||
{
|
||||
Headers = { { "Authorization", $"Bearer {token}" } },
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I submit a request to a protected endpoint with the tampered token")]
|
||||
public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("accessToken", out var t)
|
||||
? t
|
||||
: "tampered-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/api/protected"
|
||||
)
|
||||
{
|
||||
Headers = { { "Authorization", $"Bearer {token}" } },
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When(
|
||||
"I submit a request to a protected endpoint with my refresh token instead of access token"
|
||||
)]
|
||||
public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("refreshToken", out var t)
|
||||
? t
|
||||
: "refresh-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/api/protected"
|
||||
)
|
||||
{
|
||||
Headers = { { "Authorization", $"Bearer {token}" } },
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[Given("I have a valid confirmation token")]
|
||||
public void GivenIHaveAValidConfirmationToken()
|
||||
{
|
||||
scenario["confirmationToken"] = "valid-confirmation-token";
|
||||
}
|
||||
|
||||
[When("I submit a confirmation request with the expired token")]
|
||||
public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||
? t
|
||||
: "expired-confirmation-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
|
||||
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 tampered token")]
|
||||
public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||
? t
|
||||
: "tampered-confirmation-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
|
||||
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 a missing token")]
|
||||
public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm");
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I submit a confirmation request using an invalid HTTP method")]
|
||||
public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||
? t
|
||||
: "valid-confirmation-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When(
|
||||
"I submit a request to a protected endpoint with my confirmation token instead of access token"
|
||||
)]
|
||||
public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||
? t
|
||||
: "confirmation-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
"/api/protected"
|
||||
)
|
||||
{
|
||||
Headers = { { "Authorization", $"Bearer {token}" } },
|
||||
};
|
||||
|
||||
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 an invalid token")]
|
||||
public async Task WhenISubmitAConfirmationRequestWithAnInvalidToken()
|
||||
{
|
||||
var client = GetClient();
|
||||
const string token = "invalid-confirmation-token";
|
||||
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||
);
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[Then("the response JSON should have a new access token")]
|
||||
public void ThenTheResponseJsonShouldHaveANewAccessToken()
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody!);
|
||||
var payload = doc.RootElement.GetProperty("payload");
|
||||
var accessToken = ParseTokenFromPayload(
|
||||
payload,
|
||||
"accessToken",
|
||||
"AccessToken"
|
||||
);
|
||||
|
||||
accessToken.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
if (
|
||||
scenario.TryGetValue<string>(
|
||||
PreviousAccessTokenKey,
|
||||
out var previousAccessToken
|
||||
)
|
||||
)
|
||||
{
|
||||
accessToken.Should().NotBe(previousAccessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Then("the response JSON should have a new refresh token")]
|
||||
public void ThenTheResponseJsonShouldHaveANewRefreshToken()
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody!);
|
||||
var payload = doc.RootElement.GetProperty("payload");
|
||||
var refreshToken = ParseTokenFromPayload(
|
||||
payload,
|
||||
"refreshToken",
|
||||
"RefreshToken"
|
||||
);
|
||||
|
||||
refreshToken.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
if (
|
||||
scenario.TryGetValue<string>(
|
||||
PreviousRefreshTokenKey,
|
||||
out var previousRefreshToken
|
||||
)
|
||||
)
|
||||
{
|
||||
refreshToken.Should().NotBe(previousRefreshToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,19 @@
|
||||
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Domain/">
|
||||
<Project Path="Domain.Entities\Domain.Entities.csproj" />
|
||||
<Project Path="Domain.Exceptions/Domain.Exceptions.csproj" />
|
||||
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
|
||||
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Infrastructure/">
|
||||
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
|
||||
<Project Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Service/">
|
||||
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Infrastructure.Jwt;
|
||||
|
||||
public interface ITokenInfrastructure
|
||||
{
|
||||
string GenerateJwt(Guid userId, string username, DateTime expiry);
|
||||
}
|
||||
string GenerateJwt(
|
||||
Guid userId,
|
||||
string username,
|
||||
DateTime expiry,
|
||||
string secret
|
||||
);
|
||||
|
||||
Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
|
||||
}
|
||||
@@ -16,4 +16,8 @@
|
||||
Version="8.2.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,28 +3,33 @@ using System.Text;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
||||
using Domain.Exceptions;
|
||||
|
||||
namespace Infrastructure.Jwt;
|
||||
|
||||
public class JwtInfrastructure : ITokenInfrastructure
|
||||
{
|
||||
private readonly string? _secret = Environment.GetEnvironmentVariable(
|
||||
"JWT_SECRET"
|
||||
);
|
||||
|
||||
public string GenerateJwt(Guid userId, string username, DateTime expiry)
|
||||
public string GenerateJwt(
|
||||
Guid userId,
|
||||
string username,
|
||||
DateTime expiry,
|
||||
string secret
|
||||
)
|
||||
{
|
||||
var handler = new JsonWebTokenHandler();
|
||||
|
||||
var key = Encoding.UTF8.GetBytes(
|
||||
_secret ?? throw new InvalidOperationException("secret not set")
|
||||
);
|
||||
|
||||
// Base claims (always present)
|
||||
var key = Encoding.UTF8.GetBytes(secret);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(
|
||||
JwtRegisteredClaimNames.Iat,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()
|
||||
),
|
||||
new(
|
||||
JwtRegisteredClaimNames.Exp,
|
||||
new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString()
|
||||
),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
@@ -40,4 +45,36 @@ public class JwtInfrastructure : ITokenInfrastructure
|
||||
|
||||
return handler.CreateToken(tokenDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<ClaimsPrincipal> ValidateJwtAsync(
|
||||
string token,
|
||||
string secret
|
||||
)
|
||||
{
|
||||
var handler = new JsonWebTokenHandler();
|
||||
var keyBytes = Encoding.UTF8.GetBytes(
|
||||
secret
|
||||
);
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var result = await handler.ValidateTokenAsync(token, parameters);
|
||||
if (!result.IsValid || result.ClaimsIdentity == null)
|
||||
throw new UnauthorizedAccessException();
|
||||
|
||||
return new ClaimsPrincipal(result.ClaimsIdentity);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new UnauthorizedException("Invalid token");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
|
||||
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Data;
|
||||
using System.Data.Common;
|
||||
using Domain.Entities;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Infrastructure.Repository.Auth;
|
||||
|
||||
@@ -107,6 +108,78 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
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;
|
||||
}
|
||||
|
||||
// Idempotency: if already verified, treat as successful confirmation.
|
||||
if (await IsUserVerifiedAsync(userAccountId))
|
||||
{
|
||||
return user;
|
||||
}
|
||||
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "USP_CreateUserVerification";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountID_", userAccountId);
|
||||
|
||||
try
|
||||
{
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch (SqlException ex) when (IsDuplicateVerificationViolation(ex))
|
||||
{
|
||||
// A concurrent request verified this user first. Keep behavior idempotent.
|
||||
}
|
||||
|
||||
// Fetch and return the updated user
|
||||
return await GetUserByIdAsync(userAccountId);
|
||||
}
|
||||
|
||||
private async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
|
||||
command.CommandType = CommandType.Text;
|
||||
|
||||
AddParameter(command, "@UserAccountID", userAccountId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
return result != null && result != DBNull.Value;
|
||||
}
|
||||
|
||||
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
||||
{
|
||||
// 2601/2627 are duplicate key violations in SQL Server.
|
||||
return ex.Number == 2601 || ex.Number == 2627;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps a data reader row to a UserAccount entity.
|
||||
/// </summary>
|
||||
|
||||
@@ -60,4 +60,19 @@ public interface IAuthRepository
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <param name="newPasswordHash">New hashed password</param>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
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,8 +1,8 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
|
||||
162
src/Core/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs
Normal file
162
src/Core/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Jwt;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Moq;
|
||||
|
||||
namespace Service.Auth.Tests;
|
||||
|
||||
public class TokenServiceRefreshTest
|
||||
{
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly TokenService _tokenService;
|
||||
|
||||
public TokenServiceRefreshTest()
|
||||
{
|
||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
|
||||
// Set environment variables for tokens
|
||||
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
||||
|
||||
_tokenService = new TokenService(
|
||||
_tokenInfraMock.Object,
|
||||
_authRepositoryMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string refreshToken = "valid-refresh-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 userAccount = new UserAccount
|
||||
{
|
||||
UserAccountId = userId,
|
||||
Username = username,
|
||||
FirstName = "Test",
|
||||
LastName = "User",
|
||||
Email = "test@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
|
||||
// Mock the validation of refresh token
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Mock the generation of new tokens
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}");
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.GetUserByIdAsync(userId))
|
||||
.ReturnsAsync(userAccount);
|
||||
|
||||
// Act
|
||||
var result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccount.UserAccountId.Should().Be(userId);
|
||||
result.UserAccount.Username.Should().Be(username);
|
||||
result.AccessToken.Should().NotBeEmpty();
|
||||
result.RefreshToken.Should().NotBeEmpty();
|
||||
|
||||
_authRepositoryMock.Verify(
|
||||
x => x.GetUserByIdAsync(userId),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||
_tokenInfraMock.Verify(
|
||||
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
|
||||
Times.Exactly(2)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string invalidToken = "invalid-refresh-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(invalidToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string expiredToken = "expired-refresh-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(expiredToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string refreshToken = "valid-refresh-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);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.GetUserByIdAsync(userId))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(refreshToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Jwt;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Moq;
|
||||
|
||||
namespace Service.Auth.Tests;
|
||||
|
||||
public class TokenServiceValidationTest
|
||||
{
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly TokenService _tokenService;
|
||||
|
||||
public TokenServiceValidationTest()
|
||||
{
|
||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
|
||||
// Set environment variables for tokens
|
||||
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
||||
|
||||
_tokenService = new TokenService(
|
||||
_tokenInfraMock.Object,
|
||||
_authRepositoryMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string token = "valid-access-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);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
var result =
|
||||
await _tokenService.ValidateAccessTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.Username.Should().Be(username);
|
||||
result.Principal.Should().NotBeNull();
|
||||
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string token = "valid-refresh-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);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
var result =
|
||||
await _tokenService.ValidateRefreshTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.Username.Should().Be(username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string token = "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);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
var result =
|
||||
await _tokenService.ValidateConfirmationTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.Username.Should().Be(username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string token = "invalid-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string token = "expired-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException(
|
||||
"Token has expired"
|
||||
));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string username = "testuser";
|
||||
const string token = "token-without-user-id";
|
||||
|
||||
// Claims without Sub (user ID)
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*missing required claims*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string token = "token-without-username";
|
||||
|
||||
// Claims without UniqueName (username)
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*missing required claims*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string username = "testuser";
|
||||
const string token = "token-with-malformed-user-id";
|
||||
|
||||
// Claims with invalid GUID format
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*malformed user ID*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string token = "invalid-refresh-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateRefreshTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string token = "invalid-confirmation-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateConfirmationTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
}
|
||||
34
src/Core/Service/Service.Auth/ConfirmationService.cs
Normal file
34
src/Core/Service/Service.Auth/ConfirmationService.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Repository.Auth;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public class ConfirmationService(
|
||||
IAuthRepository authRepository,
|
||||
ITokenService tokenService
|
||||
) : IConfirmationService
|
||||
{
|
||||
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
|
||||
confirmationToken
|
||||
);
|
||||
|
||||
var user = await authRepository.ConfirmUserAccountAsync(
|
||||
validatedToken.UserId
|
||||
);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedException("User account not found");
|
||||
}
|
||||
|
||||
return new ConfirmationServiceReturn(
|
||||
DateTime.UtcNow,
|
||||
user.UserAccountId
|
||||
);
|
||||
}
|
||||
}
|
||||
11
src/Core/Service/Service.Auth/IConfirmationService.cs
Normal file
11
src/Core/Service/Service.Auth/IConfirmationService.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Repository.Auth;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
|
||||
|
||||
public interface IConfirmationService
|
||||
{
|
||||
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
||||
}
|
||||
@@ -2,6 +2,11 @@ using Domain.Entities;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public record LoginServiceReturn(
|
||||
UserAccount UserAccount,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
public interface ILoginService
|
||||
{
|
||||
Task<LoginServiceReturn> LoginAsync(string username, string password);
|
||||
|
||||
@@ -36,4 +36,4 @@ public interface IRegisterService
|
||||
UserAccount userAccount,
|
||||
string password
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,156 @@
|
||||
using System.Security.Claims;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Jwt;
|
||||
using Infrastructure.Repository.Auth;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public enum TokenType
|
||||
{
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
ConfirmationToken,
|
||||
}
|
||||
|
||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||
|
||||
public record RefreshTokenResult(
|
||||
UserAccount UserAccount,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
|
||||
public static class TokenServiceExpirationHours
|
||||
{
|
||||
public const double AccessTokenHours = 1;
|
||||
public const double RefreshTokenHours = 504; // 21 days
|
||||
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
||||
}
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
public string GenerateAccessToken(UserAccount user);
|
||||
public string GenerateRefreshToken(UserAccount user);
|
||||
string GenerateAccessToken(UserAccount user);
|
||||
string GenerateRefreshToken(UserAccount user);
|
||||
string GenerateConfirmationToken(UserAccount user);
|
||||
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
||||
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||
}
|
||||
|
||||
public class TokenService(ITokenInfrastructure tokenInfrastructure)
|
||||
: ITokenService
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
public string GenerateAccessToken(UserAccount userAccount)
|
||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||
private readonly IAuthRepository _authRepository;
|
||||
|
||||
private readonly string _accessTokenSecret;
|
||||
private readonly string _refreshTokenSecret;
|
||||
private readonly string _confirmationTokenSecret;
|
||||
|
||||
public TokenService(
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
IAuthRepository authRepository
|
||||
)
|
||||
{
|
||||
var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
|
||||
return tokenInfrastructure.GenerateJwt(
|
||||
userAccount.UserAccountId,
|
||||
userAccount.Username,
|
||||
jwtExpiresAt
|
||||
);
|
||||
_tokenInfrastructure = tokenInfrastructure;
|
||||
_authRepository = authRepository;
|
||||
|
||||
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set");
|
||||
|
||||
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set");
|
||||
|
||||
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||
}
|
||||
|
||||
public string GenerateRefreshToken(UserAccount userAccount)
|
||||
public string GenerateAccessToken(UserAccount user)
|
||||
{
|
||||
var jwtExpiresAt = DateTime.UtcNow.AddDays(21);
|
||||
return tokenInfrastructure.GenerateJwt(
|
||||
userAccount.UserAccountId,
|
||||
userAccount.Username,
|
||||
jwtExpiresAt
|
||||
);
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
||||
}
|
||||
|
||||
public string GenerateRefreshToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
||||
}
|
||||
|
||||
public string GenerateConfirmationToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
||||
}
|
||||
|
||||
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
||||
{
|
||||
if (typeof(T) != typeof(TokenType))
|
||||
throw new InvalidOperationException("Invalid token type");
|
||||
|
||||
var tokenTypeName = typeof(T).Name;
|
||||
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed))
|
||||
throw new InvalidOperationException("Invalid token type");
|
||||
|
||||
var tokenType = (TokenType)parsed;
|
||||
return tokenType switch
|
||||
{
|
||||
TokenType.AccessToken => GenerateAccessToken(user),
|
||||
TokenType.RefreshToken => GenerateRefreshToken(user),
|
||||
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
|
||||
_ => throw new InvalidOperationException("Invalid token type"),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||
|
||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
|
||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
|
||||
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
||||
|
||||
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
|
||||
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
|
||||
|
||||
if (!Guid.TryParse(userIdClaim, out var userId))
|
||||
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
||||
|
||||
return new ValidatedToken(userId, usernameClaim, principal);
|
||||
}
|
||||
catch (UnauthorizedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||
{
|
||||
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
var user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
var newAccess = GenerateAccessToken(user);
|
||||
var newRefresh = GenerateRefreshToken(user);
|
||||
|
||||
return new RefreshTokenResult(user, newRefresh, newAccess);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,6 @@ using Infrastructure.Repository.Auth;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public record LoginServiceReturn(
|
||||
UserAccount UserAccount,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
|
||||
public class LoginService(
|
||||
IAuthRepository authRepo,
|
||||
|
||||
@@ -53,6 +53,7 @@ public class RegisterService(
|
||||
|
||||
var accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||
|
||||
if (
|
||||
string.IsNullOrEmpty(accessToken)
|
||||
@@ -67,14 +68,15 @@ public class RegisterService(
|
||||
{
|
||||
// send confirmation email
|
||||
await emailService.SendRegistrationEmailAsync(
|
||||
createdUser,
|
||||
"some-confirmation-token"
|
||||
createdUser, confirmationToken
|
||||
);
|
||||
|
||||
emailSent = true;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Console.Error.WriteLineAsync(ex.Message);
|
||||
Console.WriteLine("Could not send email.");
|
||||
// ignored
|
||||
}
|
||||
|
||||
@@ -85,4 +87,4 @@ public class RegisterService(
|
||||
emailSent
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,17 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
<ProjectReference Include="..\Service.Emails\Service.Emails.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user