mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
Compare commits
2 Commits
f1194d3da8
...
9238036042
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9238036042 | ||
|
|
431e11e052 |
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
36
src/Core/API/API.Specs/Features/ResendConfirmation.feature
Normal file
36
src/Core/API/API.Specs/Features/ResendConfirmation.feature
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
Feature: Resend Confirmation Email
|
||||||
|
As a user who did not receive the confirmation email
|
||||||
|
I want to request a resend of the confirmation email
|
||||||
|
So that I can obtain a working confirmation link while preventing abuse
|
||||||
|
|
||||||
|
Scenario: Legitimate resend for an unconfirmed user
|
||||||
|
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 resend confirmation request for my account
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
|
Scenario: Resend is a no-op for an already confirmed user
|
||||||
|
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
|
||||||
|
And I have confirmed my account
|
||||||
|
When I submit a resend confirmation request for my account
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
|
Scenario: Resend is a no-op for a non-existent user
|
||||||
|
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 resend confirmation request for a non-existent user
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
|
Scenario: Resend requires authentication
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
When I submit a resend confirmation request without an access token
|
||||||
|
Then the response has HTTP status 401
|
||||||
@@ -7,6 +7,8 @@ public class MockEmailService : IEmailService
|
|||||||
{
|
{
|
||||||
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
||||||
|
|
||||||
|
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
|
||||||
|
|
||||||
public Task SendRegistrationEmailAsync(
|
public Task SendRegistrationEmailAsync(
|
||||||
UserAccount createdUser,
|
UserAccount createdUser,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
@@ -24,9 +26,27 @@ public class MockEmailService : IEmailService
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task SendResendConfirmationEmailAsync(
|
||||||
|
UserAccount user,
|
||||||
|
string confirmationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
SentResendConfirmationEmails.Add(
|
||||||
|
new ResendConfirmationEmail
|
||||||
|
{
|
||||||
|
UserAccount = user,
|
||||||
|
ConfirmationToken = confirmationToken,
|
||||||
|
SentAt = DateTime.UtcNow,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
SentRegistrationEmails.Clear();
|
SentRegistrationEmails.Clear();
|
||||||
|
SentResendConfirmationEmails.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RegistrationEmail
|
public class RegistrationEmail
|
||||||
@@ -35,4 +55,11 @@ public class MockEmailService : IEmailService
|
|||||||
public string ConfirmationToken { get; init; } = string.Empty;
|
public string ConfirmationToken { get; init; } = string.Empty;
|
||||||
public DateTime SentAt { get; init; }
|
public DateTime SentAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ResendConfirmationEmail
|
||||||
|
{
|
||||||
|
public UserAccount UserAccount { get; init; } = null!;
|
||||||
|
public string ConfirmationToken { get; init; } = string.Empty;
|
||||||
|
public DateTime SentAt { get; init; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -1043,4 +1124,91 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
refreshToken.Should().NotBe(previousRefreshToken);
|
refreshToken.Should().NotBe(previousRefreshToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Given("I have confirmed my account")]
|
||||||
|
public async Task GivenIHaveConfirmedMyAccount()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
|
? t
|
||||||
|
: throw new InvalidOperationException("confirmation token not found");
|
||||||
|
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);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a resend confirmation request for my account")]
|
||||||
|
public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||||
|
? id
|
||||||
|
: throw new InvalidOperationException("registered user ID not found");
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm/resend?userId={userId}"
|
||||||
|
);
|
||||||
|
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 resend confirmation request for a non-existent user")]
|
||||||
|
public async Task WhenISubmitAResendConfirmationRequestForANonExistentUser()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var fakeUserId = Guid.NewGuid();
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm/resend?userId={fakeUserId}"
|
||||||
|
);
|
||||||
|
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 resend confirmation request without an access token")]
|
||||||
|
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||||
|
? id
|
||||||
|
: Guid.NewGuid();
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm/resend?userId={userId}"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
@using Infrastructure.Email.Templates.Components
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<title>Resend Confirmation - The Biergarten App</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style>
|
||||||
|
* { font-family: Arial, sans-serif !important; }
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="margin:0; padding:0; background-color:#f4f4f4; width:100%;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#f4f4f4;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:40px 10px;">
|
||||||
|
<!--[if mso]>
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width:600px;">
|
||||||
|
<tr><td>
|
||||||
|
<![endif]-->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||||
|
style="max-width:600px; background:#ffffff; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
||||||
|
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px 40px 16px 40px; text-align:center;">
|
||||||
|
<h1 style="margin:0; color:#333333; font-size:26px; font-weight:700;">
|
||||||
|
New Confirmation Link
|
||||||
|
</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 40px 20px 40px; text-align:center;">
|
||||||
|
<p style="margin:0; color:#666666; font-size:16px; line-height:24px;">
|
||||||
|
Hi <strong style="color:#333333;">@Username</strong>, you requested another email confirmation
|
||||||
|
link.
|
||||||
|
Use the button below to verify your account.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 40px;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<!--[if mso]>
|
||||||
|
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||||
|
href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:260px;"
|
||||||
|
arcsize="10%" stroke="f" fillcolor="#f59e0b">
|
||||||
|
<w:anchorlock/>
|
||||||
|
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:700;">
|
||||||
|
Confirm Email Again
|
||||||
|
</center>
|
||||||
|
</v:roundrect>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<a href="@ConfirmationLink" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="display:inline-block; padding:16px 40px; background:#d97706; color:#ffffff; text-decoration:none; border-radius:6px; font-size:16px; font-weight:700;">
|
||||||
|
Confirm Email Again
|
||||||
|
</a>
|
||||||
|
<!--<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 40px 8px 40px; text-align:center;">
|
||||||
|
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
|
||||||
|
This replacement link expires in 24 hours.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 40px 28px 40px; text-align:center;">
|
||||||
|
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
|
||||||
|
If you did not request this, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<EmailFooter FooterText="Cheers, The Biergarten App Team" />
|
||||||
|
</table>
|
||||||
|
<!--[if mso]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string ConfirmationLink { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -30,6 +30,23 @@ public class EmailTemplateProvider(
|
|||||||
return await RenderComponentAsync<UserRegistration>(parameters);
|
return await RenderComponentAsync<UserRegistration>(parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the ResendConfirmation template with the specified parameters.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> RenderResendConfirmationEmailAsync(
|
||||||
|
string username,
|
||||||
|
string confirmationLink
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
{ nameof(ResendConfirmation.Username), username },
|
||||||
|
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink },
|
||||||
|
};
|
||||||
|
|
||||||
|
return await RenderComponentAsync<ResendConfirmation>(parameters);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generic method to render any Razor component to HTML.
|
/// Generic method to render any Razor component to HTML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -15,4 +15,15 @@ public interface IEmailTemplateProvider
|
|||||||
string username,
|
string username,
|
||||||
string confirmationLink
|
string confirmationLink
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the ResendConfirmation template with the specified parameters.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">The username to include in the email</param>
|
||||||
|
/// <param name="confirmationLink">The new confirmation link</param>
|
||||||
|
/// <returns>The rendered HTML string</returns>
|
||||||
|
Task<string> RenderResendConfirmationEmailAsync(
|
||||||
|
string username,
|
||||||
|
string confirmationLink
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await GetUserByIdAsync(userAccountId);
|
return await GetUserByIdAsync(userAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using var connection = await CreateConnection();
|
||||||
await using var command = connection.CreateCommand();
|
await using var command = connection.CreateCommand();
|
||||||
|
|||||||
@@ -75,4 +75,11 @@ public interface IAuthRepository
|
|||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a user account has been verified.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
|
/// <returns>True if the user has a verification record, false otherwise</returns>
|
||||||
|
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Domain.Exceptions;
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Infrastructure.Repository.Auth;
|
using Infrastructure.Repository.Auth;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using Service.Emails;
|
||||||
|
|
||||||
namespace Service.Auth.Tests;
|
namespace Service.Auth.Tests;
|
||||||
|
|
||||||
@@ -12,16 +13,19 @@ public class ConfirmationServiceTest
|
|||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
private readonly Mock<IEmailService> _emailServiceMock;
|
||||||
private readonly ConfirmationService _confirmationService;
|
private readonly ConfirmationService _confirmationService;
|
||||||
|
|
||||||
public ConfirmationServiceTest()
|
public ConfirmationServiceTest()
|
||||||
{
|
{
|
||||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
_tokenServiceMock = new Mock<ITokenService>();
|
_tokenServiceMock = new Mock<ITokenService>();
|
||||||
|
_emailServiceMock = new Mock<IEmailService>();
|
||||||
|
|
||||||
_confirmationService = new ConfirmationService(
|
_confirmationService = new ConfirmationService(
|
||||||
_authRepositoryMock.Object,
|
_authRepositoryMock.Object,
|
||||||
_tokenServiceMock.Object
|
_tokenServiceMock.Object,
|
||||||
|
_emailServiceMock.Object
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Infrastructure.Repository.Auth;
|
using Infrastructure.Repository.Auth;
|
||||||
|
using Service.Emails;
|
||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
public class ConfirmationService(
|
public class ConfirmationService(
|
||||||
IAuthRepository authRepository,
|
IAuthRepository authRepository,
|
||||||
ITokenService tokenService
|
ITokenService tokenService,
|
||||||
|
IEmailService emailService
|
||||||
) : IConfirmationService
|
) : IConfirmationService
|
||||||
{
|
{
|
||||||
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
||||||
@@ -31,4 +33,21 @@ public class ConfirmationService(
|
|||||||
user.UserAccountId
|
user.UserAccountId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task ResendConfirmationEmailAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var user = await authRepository.GetUserByIdAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return; // Silent return to prevent user enumeration
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await authRepository.IsUserVerifiedAsync(userId))
|
||||||
|
{
|
||||||
|
return; // Already confirmed, no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||||
|
await emailService.SendResendConfirmationEmailAsync(user, confirmationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
|
|||||||
public interface IConfirmationService
|
public interface IConfirmationService
|
||||||
{
|
{
|
||||||
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
||||||
|
Task ResendConfirmationEmailAsync(Guid userId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ public interface IEmailService
|
|||||||
UserAccount createdUser,
|
UserAccount createdUser,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public Task SendResendConfirmationEmailAsync(
|
||||||
|
UserAccount user,
|
||||||
|
string confirmationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EmailService(
|
public class EmailService(
|
||||||
@@ -17,13 +22,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(
|
||||||
@@ -38,4 +47,26 @@ public class EmailService(
|
|||||||
isHtml: true
|
isHtml: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendResendConfirmationEmailAsync(
|
||||||
|
UserAccount user,
|
||||||
|
string confirmationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var confirmationLink =
|
||||||
|
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||||
|
|
||||||
|
var emailHtml =
|
||||||
|
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
|
||||||
|
user.FirstName,
|
||||||
|
confirmationLink
|
||||||
|
);
|
||||||
|
|
||||||
|
await emailProvider.SendAsync(
|
||||||
|
user.Email,
|
||||||
|
"Confirm Your Email - The Biergarten App",
|
||||||
|
emailHtml,
|
||||||
|
isHtml: true
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user