mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
Begin work on user confirmation workflow
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
# ======================
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
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"]
|
||||
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
|
||||
@@ -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
|
||||
@@ -99,18 +101,18 @@ services:
|
||||
- nuget-cache-dev:/root/.nuget/packages
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
container_name: dev-env-mailpit
|
||||
ports:
|
||||
- "8025:8025" # Web UI
|
||||
- "1025:1025" # SMTP server
|
||||
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
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
networks:
|
||||
- devnet
|
||||
volumes:
|
||||
sqlserverdata-dev:
|
||||
driver: local
|
||||
|
||||
91
docker-compose.min.yaml
Normal file
91
docker-compose.min.yaml
Normal 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
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
volumes:
|
||||
- sqlserverdata-test:/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"]
|
||||
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
|
||||
@@ -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"
|
||||
|
||||
@@ -17,3 +17,7 @@ public record RegistrationPayload(
|
||||
string AccessToken,
|
||||
bool ConfirmationEmailSent
|
||||
);
|
||||
|
||||
public record ConfirmationPayload(
|
||||
Guid UserAccountId,
|
||||
DateTime ConfirmedDate);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
src/Core/Service/Service.Auth/IConfirmationService.cs
Normal file
21
src/Core/Service/Service.Auth/IConfirmationService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user