using MealPlan.Api.Data; using MealPlan.Api.DTOs.Auth; using MealPlan.Api.Models; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace MealPlan.Api.Services; /// /// Read-only authentication against the existing "AspNetUsers" table. /// Password verification uses ASP.NET Core Identity's so /// it is compatible with hashes produced by ASP.NET Core Identity. /// public class AuthService : IAuthService { private readonly AppDbContext _db; private readonly ITokenService _tokenService; private readonly IRefreshTokenStore _refreshTokenStore; private readonly IPasswordHasher _passwordHasher; private readonly ILogger _logger; public AuthService( AppDbContext db, ITokenService tokenService, IRefreshTokenStore refreshTokenStore, IPasswordHasher passwordHasher, ILogger logger) { _db = db; _tokenService = tokenService; _refreshTokenStore = refreshTokenStore; _passwordHasher = passwordHasher; _logger = logger; } public async Task 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 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 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 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", }; } }