PlotDirector/PlotLine/Services/AuthServices.cs
2026-06-06 19:01:13 +01:00

166 lines
6.6 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public sealed class PasswordService : IPasswordService
{
private readonly PasswordHasher<AppUser> _passwordHasher = new();
public string HashPassword(string password)
{
return _passwordHasher.HashPassword(new AppUser(), password);
}
public bool VerifyPassword(string password, string passwordHash)
{
var result = _passwordHasher.VerifyHashedPassword(new AppUser(), passwordHash, password);
return result is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded;
}
}
public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService
{
private ClaimsPrincipal? User => httpContextAccessor.HttpContext?.User;
public int? UserId
{
get
{
var value = User?.FindFirstValue(ClaimTypes.NameIdentifier);
return int.TryParse(value, out var userId) ? userId : null;
}
}
public string? Email => User?.FindFirstValue(ClaimTypes.Email);
public string? DisplayName => User?.FindFirstValue(ClaimTypes.Name);
public bool IsAuthenticated => User?.Identity?.IsAuthenticated == true;
}
public sealed class AuthService(
IUserRepository users,
IAuthenticationRepository authentication,
IPasswordService passwords,
IHttpContextAccessor httpContextAccessor,
ILogger<AuthService> logger) : IAuthService
{
public async Task<RegistrationResult> RegisterAsync(RegisterViewModel model)
{
var email = Clean(model.Email);
var displayName = Clean(model.DisplayName);
var existingUser = await users.GetByEmailAsync(email);
if (existingUser is not null)
{
return RegistrationResult.Failed("An account already exists for that email address.");
}
var passwordHash = passwords.HashPassword(model.Password);
var created = await users.CreateAsync(email, displayName, passwordHash, emailConfirmed: true);
logger.LogInformation("User {UserID} registered with email confirmation temporarily enabled for Phase 4B.", created.UserID);
return RegistrationResult.Succeeded(created.UserID);
}
public async Task<AuthenticationResult> LoginAsync(LoginViewModel model)
{
var email = Clean(model.Email);
var user = await users.GetByEmailAsync(email);
if (user is null)
{
await InsertLoginAuditAsync(null, email, false, "User not found");
return AuthenticationResult.Failed("The email address or password was not recognised.");
}
if (!passwords.VerifyPassword(model.Password, user.PasswordHash))
{
await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc: null);
await InsertLoginAuditAsync(user.UserID, email, false, "Invalid password");
return AuthenticationResult.Failed("The email address or password was not recognised.");
}
var updatedUser = await users.UpdateLoginSuccessAsync(user.UserID) ?? user;
await InsertLoginAuditAsync(user.UserID, email, true, "Login successful");
await SignInAsync(updatedUser, model.RememberMe);
return AuthenticationResult.Succeeded();
}
public async Task LogoutAsync()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is not null)
{
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
}
public Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model)
=> throw new NotImplementedException("Two-factor login will be implemented in a later phase.");
public Task<AuthenticationResult> VerifyEmailAsync(Guid token)
=> throw new NotImplementedException("Email verification will be implemented in a later phase.");
public Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model)
=> throw new NotImplementedException("Forgotten password handling will be implemented in a later phase.");
public Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model)
=> throw new NotImplementedException("Password reset handling will be implemented in a later phase.");
public Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model)
=> throw new NotImplementedException("Password changes will be implemented in a later phase.");
public Task<AuthenticationResult> EnableTwoFactorAsync(int userId)
=> throw new NotImplementedException("Two-factor setup will be implemented in a later phase.");
public Task<AuthenticationResult> DisableTwoFactorAsync(int userId)
=> throw new NotImplementedException("Two-factor setup will be implemented in a later phase.");
public Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(int userId)
=> throw new NotImplementedException("Recovery codes will be implemented in a later phase.");
private async Task SignInAsync(AppUser user, bool rememberMe)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.UserID.ToString()),
new(ClaimTypes.Name, user.DisplayName),
new(ClaimTypes.Email, user.Email)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
var properties = new AuthenticationProperties
{
IsPersistent = rememberMe,
AllowRefresh = true
};
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No active HTTP context is available.");
await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, properties);
}
private async Task InsertLoginAuditAsync(int? userId, string emailAttempted, bool wasSuccessful, string reason)
{
var httpContext = httpContextAccessor.HttpContext;
await authentication.InsertLoginAuditAsync(new UserLoginAudit
{
UserID = userId,
EmailAttempted = emailAttempted,
IpAddress = httpContext?.Connection.RemoteIpAddress?.ToString(),
UserAgent = httpContext?.Request.Headers.UserAgent.ToString(),
WasSuccessful = wasSuccessful,
Reason = reason
});
}
private static string Clean(string value) => value.Trim();
}