121 lines
5.4 KiB
C#
121 lines
5.4 KiB
C#
namespace SpaceGame.Api.Auth.Simulation;
|
|
|
|
public sealed class AuthService(
|
|
IAuthRepository authRepository,
|
|
LocalPasswordHasher passwordHasher,
|
|
ITokenService tokenService,
|
|
RefreshTokenFactory refreshTokenFactory,
|
|
IPasswordResetDelivery passwordResetDelivery)
|
|
{
|
|
public async Task<RegisterResponse> 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 new RegisterResponse(user.Id, user.Email, true);
|
|
}
|
|
|
|
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.");
|
|
}
|
|
}
|
|
}
|