166 lines
6.6 KiB
C#
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();
|
|
}
|