Begin work on user confirmation workflow

This commit is contained in:
Aaron Po
2026-02-21 20:44:49 -05:00
parent 17eb04e20c
commit 0ab2eaaec9
15 changed files with 233 additions and 41 deletions

View File

@@ -35,7 +35,9 @@ DB_PASSWORD=YourStrong!Passw0rd
# JWT Secret for signing tokens
# IMPORTANT: Generate a secure secret (minimum 32 characters)
# Command: openssl rand -base64 32
JWT_SECRET=128490218jfklsdajfdsa90f8sd0fid0safasr31jl2k1j4AFSDR
ACCESS_TOKEN_SECRET=your-secure-jwt-secret-key
REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-secret-key
CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
# ======================

View File

@@ -91,7 +91,9 @@ services:
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
JWT_SECRET: "${JWT_SECRET}"
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
restart: unless-stopped
networks:
- devnet

91
docker-compose.min.yaml Normal file
View File

@@ -0,0 +1,91 @@
services:
sqlserver:
env_file: ".env.local"
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
container_name: dev-env-sqlserver
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express"
ports:
- "1433:1433"
volumes:
- sqlserverdata-dev:/var/opt/mssql
healthcheck:
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
- devnet
database.migrations:
env_file: ".env.local"
image: database.migrations
container_name: dev-env-database-migrations
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
CLEAR_DATABASE: "true"
restart: "no"
networks:
- devnet
mailpit:
image: axllent/mailpit:latest
container_name: dev-env-mailpit
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP server
restart: unless-stopped
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
networks:
- devnet
database.seed:
env_file: ".env.local"
image: database.seed
container_name: dev-env-database-seed
depends_on:
database.migrations:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
restart: "no"
networks:
- devnet
volumes:
sqlserverdata-dev:
driver: local
nuget-cache-dev:
driver: local
networks:
devnet:
driver: bridge

View File

@@ -85,7 +85,9 @@ services:
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
JWT_SECRET: "${JWT_SECRET}"
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
volumes:
- ./test-results:/app/test-results
restart: "no"

View File

@@ -17,3 +17,7 @@ public record RegistrationPayload(
string AccessToken,
bool ConfirmationEmailSent
);
public record ConfirmationPayload(
Guid UserAccountId,
DateTime ConfirmedDate);

View File

@@ -8,7 +8,10 @@ namespace API.Core.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class AuthController(IRegisterService register, ILoginService login)
public class AuthController(
IRegisterService registerService,
ILoginService loginService,
IConfirmationService confirmationService)
: ControllerBase
{
[HttpPost("register")]
@@ -16,7 +19,7 @@ namespace API.Core.Controllers
[FromBody] RegisterRequest req
)
{
var rtn = await register.RegisterAsync(
var rtn = await registerService.RegisterAsync(
new UserAccount
{
UserAccountId = Guid.Empty,
@@ -46,7 +49,7 @@ namespace API.Core.Controllers
[HttpPost("login")]
public async Task<ActionResult> Login([FromBody] LoginRequest req)
{
var rtn = await login.LoginAsync(req.Username, req.Password);
var rtn = await loginService.LoginAsync(req.Username, req.Password);
return Ok(
new ResponseBody<LoginPayload>
@@ -61,5 +64,18 @@ namespace API.Core.Controllers
}
);
}
[HttpPost("confirm")]
public async Task<ActionResult> Confirm([FromQuery] string token)
{
var rtn = await confirmationService.ConfirmUserAsync(token);
return Ok(new ResponseBody<ConfirmationPayload>
{
Message = "User with ID " + rtn.userId + " is confirmed.",
Payload = new ConfirmationPayload(
rtn.userId, rtn.confirmedAt
)
});
}
}
}

View File

@@ -64,6 +64,7 @@ builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
// Register the exception filter
builder.Services.AddScoped<GlobalExceptionFilter>();

View File

@@ -2,5 +2,10 @@ namespace Infrastructure.Jwt;
public interface ITokenInfrastructure
{
string GenerateJwt(Guid userId, string username, DateTime expiry);
string GenerateJwt(
Guid userId,
string username,
DateTime expiry,
string secret
);
}

View File

@@ -8,16 +8,17 @@ namespace Infrastructure.Jwt;
public class JwtInfrastructure : ITokenInfrastructure
{
private readonly string? _secret = Environment.GetEnvironmentVariable(
"JWT_SECRET"
);
public string GenerateJwt(Guid userId, string username, DateTime expiry)
public string GenerateJwt(
Guid userId,
string username,
DateTime expiry,
string secret
)
{
var handler = new JsonWebTokenHandler();
var key = Encoding.UTF8.GetBytes(
_secret ?? throw new InvalidOperationException("secret not set")
secret ?? throw new InvalidOperationException("secret not set")
);
// Base claims (always present)

View File

@@ -0,0 +1,21 @@
using System.Runtime.InteropServices.JavaScript;
using Infrastructure.Repository.Auth;
namespace Service.Auth;
public record ConfirmationServiceReturn(DateTime confirmedAt, Guid userId);
public interface IConfirmationService
{
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
}
public class ConfirmationService(IAuthRepository authRepository) : IConfirmationService
{
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken)
{
return new ConfirmationServiceReturn(DateTime.Now, Guid.NewGuid());
}
}

View File

@@ -2,6 +2,11 @@ using Domain.Entities;
namespace Service.Auth;
public record LoginServiceReturn(
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
public interface ILoginService
{
Task<LoginServiceReturn> LoginAsync(string username, string password);

View File

@@ -7,28 +7,73 @@ public interface ITokenService
{
public string GenerateAccessToken(UserAccount user);
public string GenerateRefreshToken(UserAccount user);
public string GenerateConfirmationToken(UserAccount user);
}
public static class TokenServiceExpirationHours
{
public const double AccessTokenHours = 1;
public const double RefreshTokenHours = 504; // 21 days
public const double ConfirmationTokenHours = 0.5; // 30 minutes
}
public class TokenService(ITokenInfrastructure tokenInfrastructure)
: ITokenService
{
private readonly string _accessTokenSecret =
Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
?? throw new InvalidOperationException(
"ACCESS_TOKEN_SECRET environment variable is not set"
);
private readonly string _refreshTokenSecret =
Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
?? throw new InvalidOperationException(
"REFRESH_TOKEN_SECRET environment variable is not set"
);
private readonly string _confirmationTokenSecret =
Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
?? throw new InvalidOperationException(
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
);
public string GenerateAccessToken(UserAccount userAccount)
{
var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
var jwtExpiresAt = DateTime.UtcNow.AddHours(
TokenServiceExpirationHours.AccessTokenHours
);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt
jwtExpiresAt,
_accessTokenSecret
);
}
public string GenerateRefreshToken(UserAccount userAccount)
{
var jwtExpiresAt = DateTime.UtcNow.AddDays(21);
var jwtExpiresAt = DateTime.UtcNow.AddHours(
TokenServiceExpirationHours.RefreshTokenHours
);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt
jwtExpiresAt,
_refreshTokenSecret
);
}
public string GenerateConfirmationToken(UserAccount userAccount)
{
var jwtExpiresAt = DateTime.UtcNow.AddHours(
TokenServiceExpirationHours.ConfirmationTokenHours
);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt,
_confirmationTokenSecret
);
}
}

View File

@@ -5,11 +5,6 @@ using Infrastructure.Repository.Auth;
namespace Service.Auth;
public record LoginServiceReturn(
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
public class LoginService(
IAuthRepository authRepo,

View File

@@ -53,6 +53,7 @@ public class RegisterService(
var accessToken = tokenService.GenerateAccessToken(createdUser);
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
if (
string.IsNullOrEmpty(accessToken)
@@ -67,14 +68,15 @@ public class RegisterService(
{
// send confirmation email
await emailService.SendRegistrationEmailAsync(
createdUser,
"some-confirmation-token"
createdUser, confirmationToken
);
emailSent = true;
}
catch
catch (Exception ex)
{
await Console.Error.WriteLineAsync(ex.Message);
Console.WriteLine("Could not send email.");
// ignored
}