Add WEBSITE_BASE_URL environment variable and update email confirmation link

This commit is contained in:
Aaron Po
2026-03-07 02:26:01 -05:00
parent ef27d6f553
commit 3fd531c9f0
8 changed files with 116 additions and 1 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,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

@@ -6,6 +6,7 @@ Feature: User Account Confirmation
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 "is confirmed" And the response JSON should have "message" containing "is confirmed"
@@ -14,6 +15,7 @@ Feature: User Account Confirmation
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
And I submit the same confirmation request again And I submit the same confirmation request again
Then the response has HTTP status 200 Then the response has HTTP status 200
@@ -21,6 +23,8 @@ Feature: User Account Confirmation
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 token" And the response JSON should have "message" containing "Invalid token"
@@ -29,6 +33,7 @@ Feature: User Account Confirmation
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 "Invalid token" And the response JSON should have "message" containing "Invalid token"
@@ -37,12 +42,15 @@ Feature: User Account Confirmation
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 token" And the response JSON should have "message" containing "Invalid token"
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
@@ -54,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 token" 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

@@ -457,6 +457,32 @@ 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()
{ {
@@ -587,11 +613,16 @@ 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 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?token={Uri.EscapeDataString(token)}" $"/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();
@@ -606,11 +637,16 @@ 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 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?token={Uri.EscapeDataString(token)}" $"/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();
@@ -623,11 +659,16 @@ public class AuthSteps(ScenarioContext scenario)
{ {
var client = GetClient(); var client = GetClient();
const string token = "malformed-token-not-jwt"; const string 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?token={Uri.EscapeDataString(token)}" $"/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();
@@ -883,11 +924,16 @@ 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
: "expired-confirmation-token"; : "expired-confirmation-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?token={Uri.EscapeDataString(token)}" $"/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();
@@ -902,11 +948,16 @@ 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
: "tampered-confirmation-token"; : "tampered-confirmation-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?token={Uri.EscapeDataString(token)}" $"/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();
@@ -918,7 +969,13 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitAConfirmationRequestWithAMissingToken() public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
{ {
var client = GetClient(); var client = GetClient();
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm"); 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 response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
@@ -974,6 +1031,30 @@ public class AuthSteps(ScenarioContext scenario)
{ {
var client = GetClient(); var client = GetClient();
const string token = "invalid-confirmation-token"; 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( var requestMessage = new HttpRequestMessage(
HttpMethod.Post, HttpMethod.Post,

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(