173 lines
5.8 KiB
C#
173 lines
5.8 KiB
C#
using MealPlan.Api.Data;
|
|
using MealPlan.Api.DTOs.Auth;
|
|
using MealPlan.Api.Models;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MealPlan.Api.Services;
|
|
|
|
/// <summary>
|
|
/// Read-only authentication against the existing "AspNetUsers" table.
|
|
/// Password verification uses ASP.NET Core Identity's <see cref="IPasswordHasher{TUser}"/> so
|
|
/// it is compatible with hashes produced by ASP.NET Core Identity.
|
|
/// </summary>
|
|
public class AuthService : IAuthService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly ITokenService _tokenService;
|
|
private readonly IRefreshTokenStore _refreshTokenStore;
|
|
private readonly IPasswordHasher<ApplicationUser> _passwordHasher;
|
|
private readonly ILogger<AuthService> _logger;
|
|
|
|
public AuthService(
|
|
AppDbContext db,
|
|
ITokenService tokenService,
|
|
IRefreshTokenStore refreshTokenStore,
|
|
IPasswordHasher<ApplicationUser> passwordHasher,
|
|
ILogger<AuthService> logger)
|
|
{
|
|
_db = db;
|
|
_tokenService = tokenService;
|
|
_refreshTokenStore = refreshTokenStore;
|
|
_passwordHasher = passwordHasher;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<AuthResult> RegisterAsync(RegisterRequestDto request, CancellationToken ct = default)
|
|
{
|
|
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
|
|
|
|
var emailTaken = await _db.Users
|
|
.AsNoTracking()
|
|
.AnyAsync(u => u.Email != null && u.Email.ToLower() == normalizedEmail, ct);
|
|
|
|
if (emailTaken)
|
|
{
|
|
return AuthResult.Fail("An account with this email already exists.");
|
|
}
|
|
|
|
var user = new ApplicationUser
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Email = request.Email.Trim(),
|
|
UserName = string.IsNullOrWhiteSpace(request.UserName)
|
|
? request.Email.Trim()
|
|
: request.UserName.Trim(),
|
|
CreatedAt = DateTime.UtcNow,
|
|
IsActive = true,
|
|
};
|
|
|
|
user.PasswordHash = _passwordHasher.HashPassword(user, request.Password);
|
|
|
|
_db.Users.Add(user);
|
|
await _db.SaveChangesAsync(ct);
|
|
|
|
_logger.LogInformation("User {UserId} registered with email {Email}", user.Id, user.Email);
|
|
return AuthResult.Success(IssueTokens(user));
|
|
}
|
|
|
|
public async Task<AuthResult> LoginAsync(LoginRequestDto request, CancellationToken ct = default)
|
|
{
|
|
var normalizedEmail = request.Email.Trim();
|
|
|
|
var user = await _db.Users
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(u => u.Email != null && u.Email.ToLower() == normalizedEmail.ToLower(), ct);
|
|
|
|
// Uniform failure message to avoid user enumeration.
|
|
const string invalidCredentials = "Invalid email or password.";
|
|
|
|
if (user is null || string.IsNullOrEmpty(user.PasswordHash))
|
|
{
|
|
_logger.LogWarning("Login failed: user not found for {Email}", normalizedEmail);
|
|
return AuthResult.Fail(invalidCredentials);
|
|
}
|
|
|
|
if (!user.IsActive)
|
|
{
|
|
_logger.LogWarning("Login failed: inactive account {UserId}", user.Id);
|
|
return AuthResult.Fail("This account is disabled.");
|
|
}
|
|
|
|
var verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
|
if (verification == PasswordVerificationResult.Failed)
|
|
{
|
|
_logger.LogWarning("Login failed: bad password for {UserId}", user.Id);
|
|
return AuthResult.Fail(invalidCredentials);
|
|
}
|
|
|
|
_logger.LogInformation("User {UserId} logged in", user.Id);
|
|
return AuthResult.Success(IssueTokens(user));
|
|
}
|
|
|
|
public async Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct = default)
|
|
{
|
|
var existing = _refreshTokenStore.Get(refreshToken);
|
|
if (existing is null || !existing.IsActive)
|
|
{
|
|
_logger.LogWarning("Refresh failed: token missing or inactive");
|
|
return AuthResult.Fail("Invalid or expired refresh token.");
|
|
}
|
|
|
|
// Rotation: immediately invalidate the presented token.
|
|
_refreshTokenStore.Revoke(refreshToken);
|
|
|
|
if (!Guid.TryParse(existing.UserId, out var refreshUserId))
|
|
{
|
|
return AuthResult.Fail("Invalid or expired refresh token.");
|
|
}
|
|
|
|
var user = await _db.Users
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(u => u.Id == refreshUserId, ct);
|
|
|
|
if (user is null || !user.IsActive)
|
|
{
|
|
_logger.LogWarning("Refresh failed: user {UserId} missing or inactive", existing.UserId);
|
|
return AuthResult.Fail("Invalid or expired refresh token.");
|
|
}
|
|
|
|
return AuthResult.Success(IssueTokens(user));
|
|
}
|
|
|
|
public void Logout(string refreshToken) => _refreshTokenStore.Revoke(refreshToken);
|
|
|
|
public async Task<UserInfoDto?> GetUserInfoAsync(string userId, CancellationToken ct = default)
|
|
{
|
|
if (!Guid.TryParse(userId, out var parsedUserId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var user = await _db.Users
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(u => u.Id == parsedUserId, ct);
|
|
|
|
if (user is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new UserInfoDto
|
|
{
|
|
Id = user.Id.ToString(),
|
|
UserName = user.UserName,
|
|
Email = user.Email,
|
|
};
|
|
}
|
|
|
|
private TokenResponseDto IssueTokens(ApplicationUser user)
|
|
{
|
|
var accessToken = _tokenService.CreateAccessToken(user);
|
|
var refreshToken = _tokenService.CreateRefreshToken(user.Id.ToString());
|
|
|
|
return new TokenResponseDto
|
|
{
|
|
AccessToken = accessToken,
|
|
RefreshToken = refreshToken.Token,
|
|
ExpiresInSeconds = _tokenService.AccessTokenLifetimeSeconds,
|
|
TokenType = "Bearer",
|
|
};
|
|
}
|
|
}
|