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