Refactor runtime bootstrap and ship control flows

This commit is contained in:
2026-04-03 01:12:26 -04:00
parent 0bb72bee35
commit 706e1cda8f
129 changed files with 9588 additions and 3548 deletions

View 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);
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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);

View 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);

View 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; } = [];
}

View 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";
}

View 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);
}
}

View 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.");
}
}
}

View 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);
}
}
}

View 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));
}

View File

@@ -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;
}
}

View 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);
}

View File

@@ -0,0 +1,6 @@
namespace SpaceGame.Api.Auth.Simulation;
public interface IPasswordResetDelivery
{
Task<ForgotPasswordResponse> DeliverAsync(UserAccount user, string resetToken, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,8 @@
namespace SpaceGame.Api.Auth.Simulation;
public interface IPlayerIdentityResolver
{
Guid? GetCurrentPlayerId();
Guid GetRequiredPlayerId();
bool CanAccessGm();
}

View 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();
}

View 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;
}

View 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);
}
}

View 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);
}
}

View 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));
}

View 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);
}
}