mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
Compare commits
4 Commits
4e48089c18
...
3fd531c9f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fd531c9f0 | ||
|
|
ef27d6f553 | ||
|
|
4b3f3dc50a | ||
|
|
7c97825f91 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
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 the same confirmation request again")]
|
||||||
|
public async Task WhenISubmitTheSameConfirmationRequestAgain()
|
||||||
{
|
{
|
||||||
Content = new StringContent(
|
var client = GetClient();
|
||||||
body,
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
System.Text.Encoding.UTF8,
|
? t
|
||||||
"application/json"
|
: "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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await command.ExecuteNonQueryAsync();
|
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.
|
||||||
|
|||||||
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user