Initial commit
This commit is contained in:
172
MealPlan.Api/Services/AuthService.cs
Normal file
172
MealPlan.Api/Services/AuthService.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user