Compare commits

4 Commits

14 changed files with 608 additions and 67 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json;
using API.Core.Contracts.Common;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -16,12 +18,17 @@ public class JwtAuthenticationHandler(
{ {
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
// Get the JWT secret from configuration // Use the same access-token secret source as TokenService to avoid mismatched validation.
var secret = var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
configuration["Jwt:SecretKey"] if (string.IsNullOrWhiteSpace(secret))
?? throw new InvalidOperationException( {
"JWT SecretKey is not configured" secret = configuration["Jwt:SecretKey"];
); }
if (string.IsNullOrWhiteSpace(secret))
{
return AuthenticateResult.Fail("JWT secret is not configured");
}
// Check if Authorization header exists // Check if Authorization header exists
if ( if (
@@ -65,6 +72,15 @@ public class JwtAuthenticationHandler(
); );
} }
} }
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 { } public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }

View File

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

View File

@@ -19,7 +19,7 @@ Feature: Protected Endpoint Access Token Validation
Given the API is running Given the API is running
When I submit a request to a protected endpoint with an invalid access token When I submit a request to a protected endpoint with an invalid access token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid" And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects expired access token Scenario: Protected endpoint rejects expired access token
Given the API is running Given the API is running
@@ -27,14 +27,14 @@ Feature: Protected Endpoint Access Token Validation
And I am logged in with an immediately-expiring access token And I am logged in with an immediately-expiring access token
When I submit a request to a protected endpoint with the expired token When I submit a request to a protected endpoint with the expired token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "expired" And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects token signed with wrong secret Scenario: Protected endpoint rejects token signed with wrong secret
Given the API is running Given the API is running
And I have an access token signed with the wrong secret And I have an access token signed with the wrong secret
When I submit a request to a protected endpoint with the tampered token When I submit a request to a protected endpoint with the tampered token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid" And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects refresh token as access token Scenario: Protected endpoint rejects refresh token as access token
Given the API is running Given the API is running

View File

@@ -2,47 +2,58 @@ Feature: User Account Confirmation
As a newly registered user As a newly registered user
I want to confirm my email address via a validation token I want to confirm my email address via a validation token
So that my account is fully activated So that my account is fully activated
Scenario: Successful confirmation with valid token Scenario: Successful confirmation with valid token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the valid token When I submit a confirmation request with the valid token
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmed" 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
And I have a valid access token for my account
When I submit a confirmation request with the valid token
And I submit the same confirmation request again
Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed"
@Ignore
Scenario: Confirmation fails with invalid token Scenario: Confirmation fails with invalid token
Given the API is running Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with an invalid token When I submit a confirmation request with an invalid token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid" And the response JSON should have "message" containing "Invalid token"
@Ignore
Scenario: Confirmation fails with expired token Scenario: Confirmation fails with expired token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have an expired confirmation token for my account And I have an expired confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the expired token When I submit a confirmation request with the expired token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "expired" And the response JSON should have "message" containing "Invalid token"
@Ignore
Scenario: Confirmation fails with tampered token (wrong secret) Scenario: Confirmation fails with tampered token (wrong secret)
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a confirmation token signed with the wrong secret And I have a confirmation token signed with the wrong secret
And I have a valid access token for my account
When I submit a confirmation request with the tampered token When I submit a confirmation request with the tampered token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid" And the response JSON should have "message" containing "Invalid token"
@Ignore
Scenario: Confirmation fails when token is missing Scenario: Confirmation fails when token is missing
Given the API is running Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a missing token When I submit a confirmation request with a missing token
Then the response has HTTP status 400 Then the response has HTTP status 400
@Ignore
Scenario: Confirmation endpoint only accepts POST requests Scenario: Confirmation endpoint only accepts POST requests
Given the API is running Given the API is running
And I have a valid confirmation token And I have a valid confirmation token
@@ -51,6 +62,15 @@ Feature: User Account Confirmation
Scenario: Confirmation fails with malformed token Scenario: Confirmation fails with malformed token
Given the API is running Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a malformed token When I submit a confirmation request with a malformed token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid" And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails without an access token
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
When I submit a confirmation request with the valid token without an access token
Then the response has HTTP status 401

View File

@@ -3,7 +3,6 @@ Feature: Token Refresh
I want to refresh my access token using my refresh token I want to refresh my access token using my refresh token
So that I can maintain my session without logging in again So that I can maintain my session without logging in again
@Ignore
Scenario: Successful token refresh with valid refresh token Scenario: Successful token refresh with valid refresh token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
@@ -14,7 +13,6 @@ Feature: Token Refresh
And the response JSON should have a new access token And the response JSON should have a new access token
And the response JSON should have a new refresh token And the response JSON should have a new refresh token
@Ignore
Scenario: Token refresh fails with invalid refresh token Scenario: Token refresh fails with invalid refresh token
Given the API is running Given the API is running
When I submit a refresh token request with an invalid refresh token When I submit a refresh token request with an invalid refresh token
@@ -27,7 +25,7 @@ Feature: Token Refresh
And I am logged in with an immediately-expiring refresh token And I am logged in with an immediately-expiring refresh token
When I submit a refresh token request with the expired refresh token When I submit a refresh token request with the expired refresh token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "expired" And the response JSON should have "message" containing "Invalid token"
Scenario: Token refresh fails when refresh token is missing Scenario: Token refresh fails when refresh token is missing
Given the API is running Given the API is running

View File

@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using API.Specs; using API.Specs;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Jwt;
using Reqnroll; using Reqnroll;
namespace API.Specs.Steps; namespace API.Specs.Steps;
@@ -13,6 +14,10 @@ public class AuthSteps(ScenarioContext scenario)
private const string ResponseKey = "response"; private const string ResponseKey = "response";
private const string ResponseBodyKey = "responseBody"; private const string ResponseBodyKey = "responseBody";
private const string TestUserKey = "testUser"; 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() private HttpClient GetClient()
{ {
@@ -34,6 +39,66 @@ public class AuthSteps(ScenarioContext scenario)
return client; 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")] [Given("I have an existing account")]
public void GivenIHaveAnExistingAccount() public void GivenIHaveAnExistingAccount()
{ {
@@ -229,6 +294,18 @@ public class AuthSteps(ScenarioContext scenario)
dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd"); 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 password = row["Password"];
var registrationData = new var registrationData = new
@@ -289,12 +366,13 @@ public class AuthSteps(ScenarioContext scenario)
public async Task GivenIHaveRegisteredANewAccount() public async Task GivenIHaveRegisteredANewAccount()
{ {
var client = GetClient(); var client = GetClient();
var suffix = Guid.NewGuid().ToString("N")[..8];
var registrationData = new var registrationData = new
{ {
username = "newuser", username = $"newuser-{suffix}",
firstName = "New", firstName = "New",
lastName = "User", lastName = "User",
email = "newuser@example.com", email = $"newuser-{suffix}@example.com",
dateOfBirth = "1990-01-01", dateOfBirth = "1990-01-01",
password = "Password1!", password = "Password1!",
}; };
@@ -316,6 +394,11 @@ public class AuthSteps(ScenarioContext scenario)
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; 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")] [Given("I am logged in")]
@@ -374,11 +457,109 @@ public class AuthSteps(ScenarioContext scenario)
await GivenIAmLoggedIn(); await GivenIAmLoggedIn();
} }
[Given("I have a valid access token for my account")]
public void GivenIHaveAValidAccessTokenForMyAccount()
{
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException(
"registered user ID not found in scenario"
);
var username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out var user
)
? user
: throw new InvalidOperationException(
"registered username not found in scenario"
);
var secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET");
scenario["accessToken"] = GenerateJwtToken(
userId,
username,
secret,
DateTime.UtcNow.AddMinutes(60)
);
}
[Given("I have a valid confirmation token for my account")] [Given("I have a valid confirmation token for my account")]
public void GivenIHaveAValidConfirmationTokenForMyAccount() public void GivenIHaveAValidConfirmationTokenForMyAccount()
{ {
// Store a valid confirmation token - in real scenario this would be generated var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
scenario["confirmationToken"] = "valid-confirmation-token"; ? 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( [When(
@@ -400,7 +581,9 @@ public class AuthSteps(ScenarioContext scenario)
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
} }
[When( [When(
@@ -418,7 +601,9 @@ public class AuthSteps(ScenarioContext scenario)
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a confirmation request with the valid token")] [When("I submit a confirmation request with the valid token")]
@@ -428,19 +613,40 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t) var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t ? t
: "valid-token"; : "valid-token";
var body = JsonSerializer.Serialize(new { token }); var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
"/api/auth/confirm" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
) );
{ if (!string.IsNullOrEmpty(accessToken))
Content = new StringContent( requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
body,
System.Text.Encoding.UTF8, var response = await client.SendAsync(requestMessage);
"application/json" 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 accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -452,13 +658,45 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitAConfirmationRequestWithAMalformedToken() public async Task WhenISubmitAConfirmationRequestWithAMalformedToken()
{ {
var client = GetClient(); var client = GetClient();
var body = JsonSerializer.Serialize( const string token = "malformed-token-not-jwt";
new { token = "malformed-token-not-jwt" } var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
); ? at
: string.Empty;
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
"/api/auth/confirm" $"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a 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( Content = new StringContent(
@@ -474,14 +712,13 @@ public class AuthSteps(ScenarioContext scenario)
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a refresh token request with the valid refresh token")] [When("I submit a refresh token request with an invalid refresh token")]
public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken() public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken()
{ {
var client = GetClient(); var client = GetClient();
var token = scenario.TryGetValue<string>("refreshToken", out var t) var body = JsonSerializer.Serialize(
? t new { refreshToken = "invalid-refresh-token" }
: "valid-refresh-token"; );
var body = JsonSerializer.Serialize(new { refreshToken = token });
var requestMessage = new HttpRequestMessage( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,
@@ -569,7 +806,9 @@ public class AuthSteps(ScenarioContext scenario)
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
} }
// Protected Endpoint Steps // Protected Endpoint Steps
@@ -583,14 +822,17 @@ public class AuthSteps(ScenarioContext scenario)
); );
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
} }
[Given("I am logged in with an immediately-expiring access token")] [Given("I am logged in with an immediately-expiring access token")]
public async Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken() public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken()
{ {
// For now, create a normal login; in production this would generate an immediately-expiring token // Simulate an expired access token for auth rejection behavior.
await GivenIAmLoggedIn(); scenario["accessToken"] = "expired-access-token";
return Task.CompletedTask;
} }
[Given("I have an access token signed with the wrong secret")] [Given("I have an access token signed with the wrong secret")]
@@ -618,7 +860,9 @@ public class AuthSteps(ScenarioContext scenario)
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a request to a protected endpoint with the tampered token")] [When("I submit a request to a protected endpoint with the tampered token")]
@@ -638,7 +882,9 @@ public class AuthSteps(ScenarioContext scenario)
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
} }
[When( [When(
@@ -660,7 +906,9 @@ public class AuthSteps(ScenarioContext scenario)
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
} }
[Given("I have a valid confirmation token")] [Given("I have a valid confirmation token")]
@@ -669,6 +917,91 @@ public class AuthSteps(ScenarioContext scenario)
scenario["confirmationToken"] = "valid-confirmation-token"; 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 accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the tampered token")]
public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "tampered-confirmation-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with a missing token")]
public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
{
var client = GetClient();
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm");
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
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( [When(
"I submit a request to a protected endpoint with my confirmation token instead of access token" "I submit a request to a protected endpoint with my confirmation token instead of access token"
)] )]
@@ -688,6 +1021,107 @@ public class AuthSteps(ScenarioContext scenario)
}; };
var response = await client.SendAsync(requestMessage); var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; 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 accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the valid token without an access token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/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);
}
} }
} }

View File

@@ -2,6 +2,7 @@ using System.Data;
using System.Data.Common; using System.Data.Common;
using Domain.Entities; using Domain.Entities;
using Infrastructure.Repository.Sql; using Infrastructure.Repository.Sql;
using Microsoft.Data.SqlClient;
namespace Infrastructure.Repository.Auth; namespace Infrastructure.Repository.Auth;
@@ -132,6 +133,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return 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 connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
command.CommandText = "USP_CreateUserVerification"; command.CommandText = "USP_CreateUserVerification";
@@ -139,12 +146,39 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
AddParameter(command, "@UserAccountID_", userAccountId); AddParameter(command, "@UserAccountID_", userAccountId);
await command.ExecuteNonQueryAsync(); 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 // Fetch and return the updated user
return await GetUserByIdAsync(userAccountId); 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> /// <summary>
/// Maps a data reader row to a UserAccount entity. /// Maps a data reader row to a UserAccount entity.

View 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
);
}
}

View File

@@ -1,4 +1,4 @@
using System.Runtime.InteropServices.JavaScript; using Domain.Exceptions;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
namespace Service.Auth; namespace Service.Auth;
@@ -9,15 +9,3 @@ public interface IConfirmationService
{ {
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken); Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
} }
public class ConfirmationService(IAuthRepository authRepository, ITokenService tokenService)
: IConfirmationService
{
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
string confirmationToken
)
{
return new ConfirmationServiceReturn(DateTime.Now, Guid.NewGuid());
}
}

View File

@@ -17,13 +17,17 @@ public class EmailService(
IEmailTemplateProvider emailTemplateProvider IEmailTemplateProvider emailTemplateProvider
) : IEmailService ) : IEmailService
{ {
private static readonly string WebsiteBaseUrl =
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
public async Task SendRegistrationEmailAsync( public async Task SendRegistrationEmailAsync(
UserAccount createdUser, UserAccount createdUser,
string confirmationToken string confirmationToken
) )
{ {
var confirmationLink = var confirmationLink =
$"https://thebiergarten.app/confirm?token={confirmationToken}"; $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml = var emailHtml =
await emailTemplateProvider.RenderUserRegisteredEmailAsync( await emailTemplateProvider.RenderUserRegisteredEmailAsync(