Refactor runtime bootstrap and ship control flows
This commit is contained in:
17
apps/backend/Auth/Api/ForgotPasswordHandler.cs
Normal file
17
apps/backend/Auth/Api/ForgotPasswordHandler.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Api;
|
||||
|
||||
public sealed class ForgotPasswordHandler(AuthService authService) : Endpoint<ForgotPasswordRequest, ForgotPasswordResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/auth/forgot-password");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ForgotPasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await SendOkAsync(await authService.ForgotPasswordAsync(request, cancellationToken), cancellationToken);
|
||||
}
|
||||
}
|
||||
25
apps/backend/Auth/Api/LoginHandler.cs
Normal file
25
apps/backend/Auth/Api/LoginHandler.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Api;
|
||||
|
||||
public sealed class LoginHandler(AuthService authService) : Endpoint<LoginRequest, AuthSessionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/auth/login");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(LoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendOkAsync(await authService.LoginAsync(request, cancellationToken), cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apps/backend/Auth/Api/RefreshTokenHandler.cs
Normal file
25
apps/backend/Auth/Api/RefreshTokenHandler.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Api;
|
||||
|
||||
public sealed class RefreshTokenHandler(AuthService authService) : Endpoint<RefreshTokenRequest, AuthSessionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/auth/refresh");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendOkAsync(await authService.RefreshAsync(request, cancellationToken), cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apps/backend/Auth/Api/RegisterHandler.cs
Normal file
25
apps/backend/Auth/Api/RegisterHandler.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Api;
|
||||
|
||||
public sealed class RegisterHandler(AuthService authService) : Endpoint<RegisterRequest, AuthSessionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/auth/register");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendOkAsync(await authService.RegisterAsync(request, cancellationToken), cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
apps/backend/Auth/Api/ResetPasswordHandler.cs
Normal file
26
apps/backend/Auth/Api/ResetPasswordHandler.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Api;
|
||||
|
||||
public sealed class ResetPasswordHandler(AuthService authService) : Endpoint<ResetPasswordRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/auth/reset-password");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ResetPasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await authService.ResetPasswordAsync(request, cancellationToken);
|
||||
await SendNoContentAsync(cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
AddError(ex.Message);
|
||||
await SendErrorsAsync(cancellation: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
apps/backend/Auth/Contracts/AuthContracts.cs
Normal file
42
apps/backend/Auth/Contracts/AuthContracts.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
namespace SpaceGame.Api.Auth.Contracts;
|
||||
|
||||
public sealed class RegisterRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class LoginRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class RefreshTokenRequest
|
||||
{
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ForgotPasswordRequest
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ResetPasswordRequest
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record AuthSessionResponse(
|
||||
Guid UserId,
|
||||
string Email,
|
||||
IReadOnlyList<string> Roles,
|
||||
string AccessToken,
|
||||
DateTimeOffset AccessTokenExpiresAtUtc,
|
||||
string RefreshToken,
|
||||
DateTimeOffset RefreshTokenExpiresAtUtc);
|
||||
|
||||
public sealed record ForgotPasswordResponse(
|
||||
bool Accepted,
|
||||
string? ResetToken = null);
|
||||
24
apps/backend/Auth/Runtime/AuthRuntimeModels.cs
Normal file
24
apps/backend/Auth/Runtime/AuthRuntimeModels.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace SpaceGame.Api.Auth.Runtime;
|
||||
|
||||
public sealed record UserAccount(
|
||||
Guid Id,
|
||||
string Email,
|
||||
string PasswordHash,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
IReadOnlyList<string> Roles);
|
||||
|
||||
public sealed record RefreshTokenRecord(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string TokenHash,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset ExpiresAtUtc,
|
||||
DateTimeOffset? RevokedAtUtc);
|
||||
|
||||
public sealed record PasswordResetTokenRecord(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string TokenHash,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset ExpiresAtUtc,
|
||||
DateTimeOffset? ConsumedAtUtc);
|
||||
14
apps/backend/Auth/Simulation/AuthOptions.cs
Normal file
14
apps/backend/Auth/Simulation/AuthOptions.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class AuthOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
public List<SeedUserOptions> DevSeedUsers { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class SeedUserOptions
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public List<string> Roles { get; set; } = [];
|
||||
}
|
||||
13
apps/backend/Auth/Simulation/AuthPolicyNames.cs
Normal file
13
apps/backend/Auth/Simulation/AuthPolicyNames.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public static class AuthPolicyNames
|
||||
{
|
||||
public const string AdminAccess = "AdminAccess";
|
||||
public const string GmAccess = "GmAccess";
|
||||
}
|
||||
|
||||
public static class AuthRoleNames
|
||||
{
|
||||
public const string Gm = "gm";
|
||||
public const string Admin = "admin";
|
||||
}
|
||||
41
apps/backend/Auth/Simulation/AuthSchemaInitializer.cs
Normal file
41
apps/backend/Auth/Simulation/AuthSchemaInitializer.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Npgsql;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class AuthSchemaInitializer(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task EnsureSchemaAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
create table if not exists auth_users (
|
||||
id uuid primary key,
|
||||
email text not null unique,
|
||||
password_hash text not null,
|
||||
created_at_utc timestamptz not null,
|
||||
roles text[] not null default '{}'
|
||||
);
|
||||
|
||||
alter table auth_users
|
||||
add column if not exists roles text[] not null default '{}';
|
||||
|
||||
create table if not exists auth_refresh_tokens (
|
||||
id uuid primary key,
|
||||
user_id uuid not null references auth_users(id) on delete cascade,
|
||||
token_hash text not null unique,
|
||||
created_at_utc timestamptz not null,
|
||||
expires_at_utc timestamptz not null,
|
||||
revoked_at_utc timestamptz null
|
||||
);
|
||||
|
||||
create table if not exists auth_password_reset_tokens (
|
||||
id uuid primary key,
|
||||
user_id uuid not null references auth_users(id) on delete cascade,
|
||||
token_hash text not null unique,
|
||||
created_at_utc timestamptz not null,
|
||||
expires_at_utc timestamptz not null,
|
||||
consumed_at_utc timestamptz null
|
||||
);
|
||||
""");
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
120
apps/backend/Auth/Simulation/AuthService.cs
Normal file
120
apps/backend/Auth/Simulation/AuthService.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class AuthService(
|
||||
IAuthRepository authRepository,
|
||||
LocalPasswordHasher passwordHasher,
|
||||
ITokenService tokenService,
|
||||
RefreshTokenFactory refreshTokenFactory,
|
||||
IPasswordResetDelivery passwordResetDelivery)
|
||||
{
|
||||
public async Task<AuthSessionResponse> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = NormalizeEmail(request.Email);
|
||||
ValidatePassword(request.Password);
|
||||
|
||||
if (await authRepository.FindUserByEmailAsync(email, cancellationToken) is not null)
|
||||
{
|
||||
throw new InvalidOperationException("An account already exists for that email.");
|
||||
}
|
||||
|
||||
var user = await authRepository.CreateUserAsync(email, passwordHasher.HashPassword(request.Password), [], cancellationToken);
|
||||
return await CreateSessionAsync(user, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = NormalizeEmail(request.Email);
|
||||
var user = await authRepository.FindUserByEmailAsync(email, cancellationToken)
|
||||
?? throw new InvalidOperationException("Invalid email or password.");
|
||||
if (!passwordHasher.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid email or password.");
|
||||
}
|
||||
|
||||
return await CreateSessionAsync(user, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionResponse> RefreshAsync(RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
{
|
||||
throw new InvalidOperationException("Refresh token is required.");
|
||||
}
|
||||
|
||||
var tokenHash = refreshTokenFactory.HashToken(request.RefreshToken);
|
||||
var record = await authRepository.FindRefreshTokenAsync(tokenHash, cancellationToken)
|
||||
?? throw new InvalidOperationException("Refresh token is invalid.");
|
||||
if (record.RevokedAtUtc is not null || record.ExpiresAtUtc <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
throw new InvalidOperationException("Refresh token is expired.");
|
||||
}
|
||||
|
||||
var user = await authRepository.FindUserByIdAsync(record.UserId, cancellationToken)
|
||||
?? throw new InvalidOperationException("User account was not found.");
|
||||
await authRepository.RevokeRefreshTokenAsync(record.Id, cancellationToken);
|
||||
return await CreateSessionAsync(user, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = NormalizeEmail(request.Email);
|
||||
var user = await authRepository.FindUserByEmailAsync(email, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return new ForgotPasswordResponse(true);
|
||||
}
|
||||
|
||||
var resetToken = refreshTokenFactory.CreateToken();
|
||||
var resetTokenHash = refreshTokenFactory.HashToken(resetToken);
|
||||
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(30);
|
||||
await authRepository.StorePasswordResetTokenAsync(user.Id, resetTokenHash, expiresAtUtc, cancellationToken);
|
||||
return await passwordResetDelivery.DeliverAsync(user, resetToken, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
throw new InvalidOperationException("Reset token is required.");
|
||||
}
|
||||
|
||||
ValidatePassword(request.NewPassword);
|
||||
var tokenHash = refreshTokenFactory.HashToken(request.Token);
|
||||
var record = await authRepository.FindPasswordResetTokenAsync(tokenHash, cancellationToken)
|
||||
?? throw new InvalidOperationException("Reset token is invalid.");
|
||||
if (record.ConsumedAtUtc is not null || record.ExpiresAtUtc <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
throw new InvalidOperationException("Reset token is expired.");
|
||||
}
|
||||
|
||||
await authRepository.UpdatePasswordHashAsync(record.UserId, passwordHasher.HashPassword(request.NewPassword), cancellationToken);
|
||||
await authRepository.ConsumePasswordResetTokenAsync(record.Id, cancellationToken);
|
||||
await authRepository.RevokeAllRefreshTokensAsync(record.UserId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthSessionResponse> CreateSessionAsync(UserAccount user, CancellationToken cancellationToken)
|
||||
{
|
||||
var (accessToken, accessExpiresAtUtc) = tokenService.CreateAccessToken(user);
|
||||
var (refreshToken, refreshTokenHash, refreshExpiresAtUtc) = tokenService.CreateRefreshToken();
|
||||
await authRepository.StoreRefreshTokenAsync(user.Id, refreshTokenHash, refreshExpiresAtUtc, cancellationToken);
|
||||
return new AuthSessionResponse(user.Id, user.Email, user.Roles, accessToken, accessExpiresAtUtc, refreshToken, refreshExpiresAtUtc);
|
||||
}
|
||||
|
||||
private static string NormalizeEmail(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
throw new InvalidOperationException("Email is required.");
|
||||
}
|
||||
|
||||
return email.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void ValidatePassword(string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
|
||||
{
|
||||
throw new InvalidOperationException("Password must be at least 8 characters.");
|
||||
}
|
||||
}
|
||||
}
|
||||
33
apps/backend/Auth/Simulation/DevAuthSeeder.cs
Normal file
33
apps/backend/Auth/Simulation/DevAuthSeeder.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class DevAuthSeeder(
|
||||
IHostEnvironment hostEnvironment,
|
||||
IOptions<AuthOptions> authOptions,
|
||||
IAuthRepository authRepository,
|
||||
LocalPasswordHasher passwordHasher)
|
||||
{
|
||||
public async Task SeedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!hostEnvironment.IsDevelopment())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var seedUser in authOptions.Value.DevSeedUsers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(seedUser.Email) || string.IsNullOrWhiteSpace(seedUser.Password))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await authRepository.UpsertUserAsync(
|
||||
seedUser.Email.Trim().ToLowerInvariant(),
|
||||
passwordHasher.HashPassword(seedUser.Password),
|
||||
seedUser.Roles,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/backend/Auth/Simulation/DevPasswordResetDelivery.cs
Normal file
7
apps/backend/Auth/Simulation/DevPasswordResetDelivery.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class DevPasswordResetDelivery : IPasswordResetDelivery
|
||||
{
|
||||
public Task<ForgotPasswordResponse> DeliverAsync(UserAccount user, string resetToken, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new ForgotPasswordResponse(true, resetToken));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class HttpContextPlayerIdentityResolver(IHttpContextAccessor httpContextAccessor) : IPlayerIdentityResolver
|
||||
{
|
||||
public Guid? GetCurrentPlayerId()
|
||||
{
|
||||
var subject = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
|
||||
return Guid.TryParse(subject, out var playerId) ? playerId : null;
|
||||
}
|
||||
|
||||
public Guid GetRequiredPlayerId() =>
|
||||
GetCurrentPlayerId() ?? throw new InvalidOperationException("Authenticated player identity is required.");
|
||||
|
||||
public bool CanAccessGm()
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
return user?.IsInRole("gm") == true || user?.IsInRole("admin") == true;
|
||||
}
|
||||
}
|
||||
17
apps/backend/Auth/Simulation/IAuthRepository.cs
Normal file
17
apps/backend/Auth/Simulation/IAuthRepository.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public interface IAuthRepository
|
||||
{
|
||||
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||
Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
||||
Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken);
|
||||
Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);
|
||||
Task<RefreshTokenRecord?> FindRefreshTokenAsync(string tokenHash, CancellationToken cancellationToken);
|
||||
Task RevokeRefreshTokenAsync(Guid refreshTokenId, CancellationToken cancellationToken);
|
||||
Task RevokeAllRefreshTokensAsync(Guid userId, CancellationToken cancellationToken);
|
||||
Task StorePasswordResetTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken);
|
||||
Task<PasswordResetTokenRecord?> FindPasswordResetTokenAsync(string tokenHash, CancellationToken cancellationToken);
|
||||
Task ConsumePasswordResetTokenAsync(Guid passwordResetTokenId, CancellationToken cancellationToken);
|
||||
Task UpdatePasswordHashAsync(Guid userId, string passwordHash, CancellationToken cancellationToken);
|
||||
}
|
||||
6
apps/backend/Auth/Simulation/IPasswordResetDelivery.cs
Normal file
6
apps/backend/Auth/Simulation/IPasswordResetDelivery.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public interface IPasswordResetDelivery
|
||||
{
|
||||
Task<ForgotPasswordResponse> DeliverAsync(UserAccount user, string resetToken, CancellationToken cancellationToken);
|
||||
}
|
||||
8
apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs
Normal file
8
apps/backend/Auth/Simulation/IPlayerIdentityResolver.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public interface IPlayerIdentityResolver
|
||||
{
|
||||
Guid? GetCurrentPlayerId();
|
||||
Guid GetRequiredPlayerId();
|
||||
bool CanAccessGm();
|
||||
}
|
||||
7
apps/backend/Auth/Simulation/ITokenService.cs
Normal file
7
apps/backend/Auth/Simulation/ITokenService.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
(string Token, DateTimeOffset ExpiresAtUtc) CreateAccessToken(UserAccount user);
|
||||
(string Token, string TokenHash, DateTimeOffset ExpiresAtUtc) CreateRefreshToken();
|
||||
}
|
||||
10
apps/backend/Auth/Simulation/JwtOptions.cs
Normal file
10
apps/backend/Auth/Simulation/JwtOptions.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
public string Issuer { get; set; } = "space-game";
|
||||
public string Audience { get; set; } = "space-game-viewer";
|
||||
public string SigningKey { get; set; } = string.Empty;
|
||||
public int AccessTokenLifetimeMinutes { get; set; } = 30;
|
||||
public int RefreshTokenLifetimeDays { get; set; } = 30;
|
||||
}
|
||||
51
apps/backend/Auth/Simulation/JwtTokenService.cs
Normal file
51
apps/backend/Auth/Simulation/JwtTokenService.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class JwtTokenService(
|
||||
IOptions<JwtOptions> jwtOptions,
|
||||
RefreshTokenFactory refreshTokenFactory) : ITokenService
|
||||
{
|
||||
public (string Token, DateTimeOffset ExpiresAtUtc) CreateAccessToken(UserAccount user)
|
||||
{
|
||||
var options = jwtOptions.Value;
|
||||
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(options.AccessTokenLifetimeMinutes, 5));
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Email, user.Email),
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
}.ToList();
|
||||
|
||||
foreach (var role in user.Roles)
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
claims.Add(new Claim("role", role));
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: options.Issuer,
|
||||
audience: options.Audience,
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,
|
||||
expires: expiresAtUtc.UtcDateTime,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAtUtc);
|
||||
}
|
||||
|
||||
public (string Token, string TokenHash, DateTimeOffset ExpiresAtUtc) CreateRefreshToken()
|
||||
{
|
||||
var token = refreshTokenFactory.CreateToken();
|
||||
var tokenHash = refreshTokenFactory.HashToken(token);
|
||||
var expiresAtUtc = DateTimeOffset.UtcNow.AddDays(Math.Max(jwtOptions.Value.RefreshTokenLifetimeDays, 1));
|
||||
return (token, tokenHash, expiresAtUtc);
|
||||
}
|
||||
}
|
||||
42
apps/backend/Auth/Simulation/LocalPasswordHasher.cs
Normal file
42
apps/backend/Auth/Simulation/LocalPasswordHasher.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class LocalPasswordHasher
|
||||
{
|
||||
private const int SaltSize = 16;
|
||||
private const int KeySize = 32;
|
||||
private const int IterationCount = 120_000;
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(password);
|
||||
|
||||
Span<byte> salt = stackalloc byte[SaltSize];
|
||||
RandomNumberGenerator.Fill(salt);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, IterationCount, HashAlgorithmName.SHA256, KeySize);
|
||||
return $"pbkdf2-sha256${IterationCount}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string encodedHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(password);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(encodedHash);
|
||||
|
||||
var parts = encodedHash.Split('$', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 4 || !string.Equals(parts[0], "pbkdf2-sha256", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[1], out var iterations))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var salt = Convert.FromBase64String(parts[2]);
|
||||
var expected = Convert.FromBase64String(parts[3]);
|
||||
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expected.Length);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
}
|
||||
199
apps/backend/Auth/Simulation/PostgresAuthRepository.cs
Normal file
199
apps/backend/Auth/Simulation/PostgresAuthRepository.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using Npgsql;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthRepository
|
||||
{
|
||||
public async Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, email, password_hash, created_at_utc, roles
|
||||
from auth_users
|
||||
where email = $1
|
||||
""");
|
||||
command.Parameters.AddWithValue(email);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<UserAccount?> FindUserByIdAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, email, password_hash, created_at_utc, roles
|
||||
from auth_users
|
||||
where id = $1
|
||||
""");
|
||||
command.Parameters.AddWithValue(userId);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<UserAccount> CreateUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var createdAtUtc = DateTimeOffset.UtcNow;
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
insert into auth_users (id, email, password_hash, created_at_utc, roles)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
""");
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(email);
|
||||
command.Parameters.AddWithValue(passwordHash);
|
||||
command.Parameters.AddWithValue(createdAtUtc);
|
||||
command.Parameters.AddWithValue(roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray());
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
return new UserAccount(userId, email, passwordHash, createdAtUtc, roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
public async Task<UserAccount> UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection<string> roles, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedRoles = roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray();
|
||||
var userId = Guid.NewGuid();
|
||||
var createdAtUtc = DateTimeOffset.UtcNow;
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
insert into auth_users (id, email, password_hash, created_at_utc, roles)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (email) do update
|
||||
set password_hash = excluded.password_hash,
|
||||
roles = excluded.roles
|
||||
returning id, email, password_hash, created_at_utc, roles
|
||||
""");
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(email);
|
||||
command.Parameters.AddWithValue(passwordHash);
|
||||
command.Parameters.AddWithValue(createdAtUtc);
|
||||
command.Parameters.AddWithValue(normalizedRoles);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
await reader.ReadAsync(cancellationToken);
|
||||
return ReadUser(reader);
|
||||
}
|
||||
|
||||
public async Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
insert into auth_refresh_tokens (id, user_id, token_hash, created_at_utc, expires_at_utc, revoked_at_utc)
|
||||
values ($1, $2, $3, $4, $5, null)
|
||||
""");
|
||||
command.Parameters.AddWithValue(Guid.NewGuid());
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(tokenHash);
|
||||
command.Parameters.AddWithValue(DateTimeOffset.UtcNow);
|
||||
command.Parameters.AddWithValue(expiresAtUtc);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenRecord?> FindRefreshTokenAsync(string tokenHash, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, user_id, token_hash, created_at_utc, expires_at_utc, revoked_at_utc
|
||||
from auth_refresh_tokens
|
||||
where token_hash = $1
|
||||
""");
|
||||
command.Parameters.AddWithValue(tokenHash);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RefreshTokenRecord(
|
||||
reader.GetGuid(0),
|
||||
reader.GetGuid(1),
|
||||
reader.GetString(2),
|
||||
reader.GetFieldValue<DateTimeOffset>(3),
|
||||
reader.GetFieldValue<DateTimeOffset>(4),
|
||||
reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5));
|
||||
}
|
||||
|
||||
public async Task RevokeRefreshTokenAsync(Guid refreshTokenId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
update auth_refresh_tokens
|
||||
set revoked_at_utc = $2
|
||||
where id = $1 and revoked_at_utc is null
|
||||
""");
|
||||
command.Parameters.AddWithValue(refreshTokenId);
|
||||
command.Parameters.AddWithValue(DateTimeOffset.UtcNow);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RevokeAllRefreshTokensAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
update auth_refresh_tokens
|
||||
set revoked_at_utc = $2
|
||||
where user_id = $1 and revoked_at_utc is null
|
||||
""");
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(DateTimeOffset.UtcNow);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task StorePasswordResetTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
insert into auth_password_reset_tokens (id, user_id, token_hash, created_at_utc, expires_at_utc, consumed_at_utc)
|
||||
values ($1, $2, $3, $4, $5, null)
|
||||
""");
|
||||
command.Parameters.AddWithValue(Guid.NewGuid());
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(tokenHash);
|
||||
command.Parameters.AddWithValue(DateTimeOffset.UtcNow);
|
||||
command.Parameters.AddWithValue(expiresAtUtc);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PasswordResetTokenRecord?> FindPasswordResetTokenAsync(string tokenHash, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
select id, user_id, token_hash, created_at_utc, expires_at_utc, consumed_at_utc
|
||||
from auth_password_reset_tokens
|
||||
where token_hash = $1
|
||||
""");
|
||||
command.Parameters.AddWithValue(tokenHash);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PasswordResetTokenRecord(
|
||||
reader.GetGuid(0),
|
||||
reader.GetGuid(1),
|
||||
reader.GetString(2),
|
||||
reader.GetFieldValue<DateTimeOffset>(3),
|
||||
reader.GetFieldValue<DateTimeOffset>(4),
|
||||
reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5));
|
||||
}
|
||||
|
||||
public async Task ConsumePasswordResetTokenAsync(Guid passwordResetTokenId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
update auth_password_reset_tokens
|
||||
set consumed_at_utc = $2
|
||||
where id = $1 and consumed_at_utc is null
|
||||
""");
|
||||
command.Parameters.AddWithValue(passwordResetTokenId);
|
||||
command.Parameters.AddWithValue(DateTimeOffset.UtcNow);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdatePasswordHashAsync(Guid userId, string passwordHash, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand("""
|
||||
update auth_users
|
||||
set password_hash = $2
|
||||
where id = $1
|
||||
""");
|
||||
command.Parameters.AddWithValue(userId);
|
||||
command.Parameters.AddWithValue(passwordHash);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static UserAccount ReadUser(NpgsqlDataReader reader) => new(
|
||||
reader.GetGuid(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetFieldValue<DateTimeOffset>(3),
|
||||
reader.GetFieldValue<string[]>(4));
|
||||
}
|
||||
21
apps/backend/Auth/Simulation/RefreshTokenFactory.cs
Normal file
21
apps/backend/Auth/Simulation/RefreshTokenFactory.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SpaceGame.Api.Auth.Simulation;
|
||||
|
||||
public sealed class RefreshTokenFactory
|
||||
{
|
||||
public string CreateToken()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
public string HashToken(string token)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(token));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user