From 3fd531c9f00a2edff559010efa4d1fbead2d18e2 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 7 Mar 2026 02:26:01 -0500 Subject: [PATCH] Add WEBSITE_BASE_URL environment variable and update email confirmation link --- docker-compose.dev.yaml | 1 + docker-compose.prod.yaml | 1 + docker-compose.test.yaml | 1 + docs/environment-variables.md | 5 ++ .../API.Core/Controllers/AuthController.cs | 5 ++ .../API.Specs/Features/Confirmation.feature | 17 ++++ src/Core/API/API.Specs/Steps/AuthSteps.cs | 81 +++++++++++++++++++ .../Service/Service.Emails/EmailService.cs | 6 +- 8 files changed, 116 insertions(+), 1 deletion(-) diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 6593c7e..4b66ed0 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -94,6 +94,7 @@ services: ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" + WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" restart: unless-stopped networks: - devnet diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index b8926d2..72eaf12 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -69,6 +69,7 @@ services: ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" + WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" restart: unless-stopped networks: - prodnet diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 6162b28..dea63c2 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -88,6 +88,7 @@ services: ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" + WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" volumes: - ./test-results:/app/test-results restart: "no" diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 90adb68..d664a39 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -71,6 +71,9 @@ REFRESH_TOKEN_SECRET= # Signs long-lived refresh t # Confirmation token secret (30-minute tokens) CONFIRMATION_TOKEN_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**: @@ -292,6 +295,7 @@ touch .env.local | `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token secret | | `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token secret | | `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token secret | +| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails | | **Authentication (Frontend)** | | `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation | | `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset | @@ -359,6 +363,7 @@ DB_PASSWORD=Dev_Password_123! ACCESS_TOKEN_SECRET= REFRESH_TOKEN_SECRET= CONFIRMATION_TOKEN_SECRET= +WEBSITE_BASE_URL=http://localhost:3000 # Migration CLEAR_DATABASE=true diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs index e9ce3b4..7b2dcc4 100644 --- a/src/Core/API/API.Core/Controllers/AuthController.cs +++ b/src/Core/API/API.Core/Controllers/AuthController.cs @@ -1,6 +1,7 @@ using API.Core.Contracts.Auth; using API.Core.Contracts.Common; using Domain.Entities; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Service.Auth; @@ -8,6 +9,7 @@ namespace API.Core.Controllers { [ApiController] [Route("api/[controller]")] + [Authorize(AuthenticationSchemes = "JWT")] public class AuthController( IRegisterService registerService, ILoginService loginService, @@ -15,6 +17,7 @@ namespace API.Core.Controllers ITokenService tokenService ) : ControllerBase { + [AllowAnonymous] [HttpPost("register")] public async Task> Register( [FromBody] RegisterRequest req @@ -47,6 +50,7 @@ namespace API.Core.Controllers return Created("/", response); } + [AllowAnonymous] [HttpPost("login")] public async Task Login([FromBody] LoginRequest req) { @@ -82,6 +86,7 @@ namespace API.Core.Controllers ); } + [AllowAnonymous] [HttpPost("refresh")] public async Task Refresh( [FromBody] RefreshTokenRequest req diff --git a/src/Core/API/API.Specs/Features/Confirmation.feature b/src/Core/API/API.Specs/Features/Confirmation.feature index ac77184..0657aab 100644 --- a/src/Core/API/API.Specs/Features/Confirmation.feature +++ b/src/Core/API/API.Specs/Features/Confirmation.feature @@ -6,6 +6,7 @@ Feature: User Account Confirmation 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 Then the response has HTTP status 200 And the response JSON should have "message" containing "is confirmed" @@ -14,6 +15,7 @@ Feature: User Account Confirmation 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 @@ -21,6 +23,8 @@ Feature: User Account Confirmation Scenario: Confirmation fails with invalid token 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 Then the response has HTTP status 401 And the response JSON should have "message" containing "Invalid token" @@ -29,6 +33,7 @@ Feature: User Account Confirmation Given the API is running And I have registered a new 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 Then the response has HTTP status 401 And the response JSON should have "message" containing "Invalid token" @@ -37,12 +42,15 @@ Feature: User Account Confirmation Given the API is running And I have registered a new account 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 Then the response has HTTP status 401 And the response JSON should have "message" containing "Invalid token" Scenario: Confirmation fails when token is missing Given the API is running + 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 Then the response has HTTP status 400 @@ -54,6 +62,15 @@ Feature: User Account Confirmation Scenario: Confirmation fails with malformed token 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 Then the response has HTTP status 401 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 diff --git a/src/Core/API/API.Specs/Steps/AuthSteps.cs b/src/Core/API/API.Specs/Steps/AuthSteps.cs index 26b26c9..bb99c39 100644 --- a/src/Core/API/API.Specs/Steps/AuthSteps.cs +++ b/src/Core/API/API.Specs/Steps/AuthSteps.cs @@ -457,6 +457,32 @@ public class AuthSteps(ScenarioContext scenario) await GivenIAmLoggedIn(); } + [Given("I have a valid access token for my account")] + public void GivenIHaveAValidAccessTokenForMyAccount() + { + var userId = scenario.TryGetValue(RegisteredUserIdKey, out var id) + ? id + : throw new InvalidOperationException( + "registered user ID not found in scenario" + ); + var username = scenario.TryGetValue( + 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")] public void GivenIHaveAValidConfirmationTokenForMyAccount() { @@ -587,11 +613,16 @@ public class AuthSteps(ScenarioContext scenario) var token = scenario.TryGetValue("confirmationToken", out var t) ? t : "valid-token"; + var accessToken = scenario.TryGetValue("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(); @@ -606,11 +637,16 @@ public class AuthSteps(ScenarioContext scenario) var token = scenario.TryGetValue("confirmationToken", out var t) ? t : "valid-token"; + var accessToken = scenario.TryGetValue("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(); @@ -623,11 +659,16 @@ public class AuthSteps(ScenarioContext scenario) { var client = GetClient(); const string token = "malformed-token-not-jwt"; + var accessToken = scenario.TryGetValue("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(); @@ -883,11 +924,16 @@ public class AuthSteps(ScenarioContext scenario) var token = scenario.TryGetValue("confirmationToken", out var t) ? t : "expired-confirmation-token"; + var accessToken = scenario.TryGetValue("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(); @@ -902,11 +948,16 @@ public class AuthSteps(ScenarioContext scenario) var token = scenario.TryGetValue("confirmationToken", out var t) ? t : "tampered-confirmation-token"; + var accessToken = scenario.TryGetValue("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(); @@ -918,7 +969,13 @@ public class AuthSteps(ScenarioContext scenario) public async Task WhenISubmitAConfirmationRequestWithAMissingToken() { var client = GetClient(); + var accessToken = scenario.TryGetValue("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(); @@ -974,6 +1031,30 @@ public class AuthSteps(ScenarioContext scenario) { var client = GetClient(); const string token = "invalid-confirmation-token"; + var accessToken = scenario.TryGetValue("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("confirmationToken", out var t) + ? t + : "valid-token"; var requestMessage = new HttpRequestMessage( HttpMethod.Post, diff --git a/src/Core/Service/Service.Emails/EmailService.cs b/src/Core/Service/Service.Emails/EmailService.cs index 7532769..18ce350 100644 --- a/src/Core/Service/Service.Emails/EmailService.cs +++ b/src/Core/Service/Service.Emails/EmailService.cs @@ -17,13 +17,17 @@ public class EmailService( IEmailTemplateProvider emailTemplateProvider ) : 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( UserAccount createdUser, string confirmationToken ) { var confirmationLink = - $"https://thebiergarten.app/confirm?token={confirmationToken}"; + $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}"; var emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync(