Refactor runtime bootstrap and ship control flows
This commit is contained in:
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user