using System.Security.Claims; using System.Net; using System.Net.Mail; using System.Net.Mime; using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public sealed class PasswordService : IPasswordService { private readonly PasswordHasher _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 EmailService( IOptions emailOptions, IEmailQueueRepository emailQueue, IHttpContextAccessor httpContextAccessor, ILogger logger) : IEmailService { private readonly EmailSettings _settings = emailOptions.Value; public Task SendVerificationEmailAsync(string email, string displayName, Guid token) { var link = BuildAccountUrl("VerifyEmail", token); var (plainText, html) = BuildTemplate( "Verify your PlotWeaver Studio account", displayName, "Please verify your email address to finish setting up your PlotWeaver Studio account.", "Verify account", link, "This link expires in 24 hours."); return QueueAsync(email, displayName, "Verify your PlotWeaver Studio account", plainText, html); } public Task SendPasswordResetEmailAsync(string email, string displayName, Guid token) { var link = BuildAccountUrl("ResetPassword", token); var (plainText, html) = BuildTemplate( "Reset your PlotWeaver Studio password", displayName, "Use the link below to choose a new password for your PlotWeaver Studio account.", "Reset password", link, "This link expires in 24 hours. If you did not request a reset, you can ignore this email."); return QueueAsync(email, displayName, "Reset your PlotWeaver Studio password", plainText, html); } private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string html) { var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem { ToEmail = toEmail, ToName = CleanDisplayName(displayName), FromEmail = GetFromAddress(), FromName = CleanDisplayName(_settings.FromName), Subject = subject, PlainTextBody = plainText, HtmlBody = html, MaxAttempts = 5 }); logger.LogInformation("Queued email {EmailQueueID} with subject '{Subject}' for {Email}.", emailQueueId, subject, toEmail); } private string BuildAccountUrl(string action, Guid token) { var request = httpContextAccessor.HttpContext?.Request; var host = request?.Host.Value; if (string.IsNullOrWhiteSpace(host)) { throw new InvalidOperationException("Cannot build account email link without an active request host."); } return $"https://{host}/Account/{action}?token={Uri.EscapeDataString(token.ToString("D"))}"; } private static (string PlainText, string Html) BuildTemplate(string title, string displayName, string intro, string buttonText, string link, string footnote) { var safeName = WebUtility.HtmlEncode(displayName); var safeTitle = WebUtility.HtmlEncode(title); var safeIntro = WebUtility.HtmlEncode(intro); var safeButton = WebUtility.HtmlEncode(buttonText); var safeLink = WebUtility.HtmlEncode(link); var safeFootnote = WebUtility.HtmlEncode(footnote); var plainText = $""" {title} Hello {displayName}, {intro} {buttonText}: {link} {footnote} """; var html = $"""

PlotWeaver Studio

{safeTitle}

Hello {safeName},

{safeIntro}

{safeButton}

If the button does not work, copy and paste this link into your browser:

{safeLink}

{safeFootnote}

"""; return (plainText, html); } private static string CleanDisplayName(string value) { return string.IsNullOrWhiteSpace(value) ? "PlotWeaver Studio" : value.Trim(); } private string GetFromAddress() { return string.IsNullOrWhiteSpace(_settings.FromAddress) ? "hello@plotweaver.studio" : _settings.FromAddress.Trim(); } } public interface IQueuedEmailSender { Task SendAsync(EmailQueueItem email, CancellationToken cancellationToken); } public sealed class QueuedEmailSender( IOptions emailOptions, ILogger logger) : IQueuedEmailSender { private readonly EmailSettings _settings = emailOptions.Value; public async Task SendAsync(EmailQueueItem email, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_settings.SmtpServer) || string.IsNullOrWhiteSpace(_settings.Username) || string.IsNullOrWhiteSpace(_settings.Password)) { throw new InvalidOperationException("SMTP settings are not configured."); } using var message = new MailMessage { From = new MailAddress(email.FromEmail, CleanDisplayName(email.FromName)), Subject = email.Subject, Body = email.PlainTextBody, IsBodyHtml = false }; message.To.Add(new MailAddress(email.ToEmail, CleanDisplayName(email.ToName))); message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(email.PlainTextBody, null, MediaTypeNames.Text.Plain)); if (!string.IsNullOrWhiteSpace(email.HtmlBody)) { message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(email.HtmlBody, null, MediaTypeNames.Text.Html)); } using var client = new SmtpClient(_settings.SmtpServer, _settings.Port) { EnableSsl = _settings.EnableSsl, Credentials = new NetworkCredential(_settings.Username, _settings.Password), Timeout = 30000 }; cancellationToken.ThrowIfCancellationRequested(); await client.SendMailAsync(message, cancellationToken); logger.LogInformation("Queued email {EmailQueueID} sent to {Email}.", email.EmailQueueID, email.ToEmail); } private static string CleanDisplayName(string? value) { return string.IsNullOrWhiteSpace(value) ? "PlotWeaver Studio" : value.Trim(); } } public sealed class AuthService( IUserRepository users, IAuthenticationRepository authentication, IPasswordService passwords, ITwoFactorService twoFactor, IEmailService emails, IHttpContextAccessor httpContextAccessor, ILogger logger) : IAuthService { private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24); private static readonly TimeSpan PendingTwoFactorLifetime = TimeSpan.FromMinutes(10); private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15); private const int MaxFailedLoginAttempts = 5; private const string SecurityStampClaimType = "PlotWeaver.SecurityStamp"; private const string PendingTwoFactorUserIdKey = "PlotWeaver.PendingTwoFactor.UserID"; private const string PendingTwoFactorRememberMeKey = "PlotWeaver.PendingTwoFactor.RememberMe"; private const string PendingTwoFactorReturnUrlKey = "PlotWeaver.PendingTwoFactor.ReturnUrl"; private const string PendingTwoFactorExpiresUtcKey = "PlotWeaver.PendingTwoFactor.ExpiresUtc"; public async Task 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: false); var token = Guid.NewGuid(); await authentication.CreateEmailVerificationTokenAsync(created.UserID, token, DateTime.UtcNow.Add(TokenLifetime)); await emails.SendVerificationEmailAsync(created.Email, created.DisplayName, token); await InsertLoginAuditAsync(created.UserID, created.Email, true, "Email verification requested"); logger.LogInformation("User {UserID} registered and was sent an email verification token.", created.UserID); return RegistrationResult.Succeeded(created.UserID); } public async Task LoginAsync(LoginViewModel model) { var email = Clean(model.Email); var user = await users.GetByEmailAsync(email); if (user is null) { await InsertLoginAuditAsync(null, email, false, "Login failed, unknown email"); return AuthenticationResult.Failed("Invalid email address or password."); } if (IsLockedOut(user)) { await InsertLoginAuditAsync(user.UserID, email, false, "Login blocked, account locked"); return AuthenticationResult.Failed("This account is temporarily locked. Please try again later."); } if (!user.EmailConfirmed) { await InsertLoginAuditAsync(user.UserID, email, false, "Login blocked, email not verified"); return AuthenticationResult.Failed("Please verify your email address before signing in."); } if (!passwords.VerifyPassword(model.Password, user.PasswordHash)) { var lockoutEndUtc = user.FailedLoginAttempts + 1 >= MaxFailedLoginAttempts ? DateTime.UtcNow.Add(LockoutDuration) : (DateTime?)null; await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc); await InsertLoginAuditAsync(user.UserID, email, false, lockoutEndUtc.HasValue ? "Login failed, account locked" : "Login failed, bad password"); return lockoutEndUtc.HasValue ? AuthenticationResult.Failed("This account is temporarily locked. Please try again later.") : AuthenticationResult.Failed("Invalid email address or password."); } if (user.TwoFactorEnabled) { SetPendingTwoFactor(user.UserID, model.RememberMe, model.ReturnUrl); await InsertLoginAuditAsync(user.UserID, email, false, "Two-factor required"); return AuthenticationResult.TwoFactorRequired(); } 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 bool HasPendingTwoFactorLogin() => GetPendingTwoFactor() is not null; public string? GetPendingTwoFactorReturnUrl() => GetPendingTwoFactor()?.ReturnUrl; public async Task LogoutAsync() { var httpContext = httpContextAccessor.HttpContext; if (httpContext is not null) { var userId = CurrentUserId(); if (userId.HasValue) { var email = httpContext.User.FindFirstValue(ClaimTypes.Email); await InsertLoginAuditAsync(userId.Value, email ?? string.Empty, true, "Logout"); } await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); } } public async Task CompleteTwoFactorLoginAsync(TwoFactorCodeViewModel model) { var context = await GetPendingTwoFactorContextAsync(); if (context is null) { return AuthenticationResult.Failed("Your two-factor login has expired. Please log in again."); } if (!twoFactor.ValidateCode(context.Value.Settings.SecretKey, model.Code, context.Value.Settings.LastUsedTimeStep, out var matchedTimeStep) || !await authentication.UpdateLastUsedTimeStepAsync(context.Value.User.UserID, matchedTimeStep)) { await InsertLoginAuditAsync(context.Value.User.UserID, context.Value.User.Email, false, "2FA challenge failed"); return AuthenticationResult.Failed("The authentication code was not recognised."); } return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "2FA challenge succeeded"); } public async Task CompleteTwoFactorRecoveryLoginAsync(TwoFactorRecoveryViewModel model) { var context = await GetPendingTwoFactorContextAsync(); if (context is null) { return AuthenticationResult.Failed("Your two-factor login has expired. Please log in again."); } if (!await TryUseRecoveryCodeAsync(context.Value.User.UserID, model.RecoveryCode)) { await InsertLoginAuditAsync(context.Value.User.UserID, context.Value.User.Email, false, "2FA challenge failed"); return AuthenticationResult.Failed("The recovery code was not recognised."); } return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "Recovery code used"); } public async Task VerifyEmailAsync(Guid token) { var storedToken = await authentication.GetEmailVerificationTokenAsync(token); if (storedToken is null) { logger.LogWarning("Email verification failed because token {Token} was not found.", token); return AuthenticationResult.Failed("This verification link is not valid."); } if (storedToken.UsedUtc.HasValue) { return AuthenticationResult.Failed("This verification link has already been used."); } if (storedToken.ExpiryUtc < DateTime.UtcNow) { return AuthenticationResult.Failed("This verification link has expired."); } var confirmed = await users.ConfirmEmailAsync(storedToken.UserID, token); if (confirmed) { var verifiedUser = await users.GetByIdAsync(storedToken.UserID); await InsertLoginAuditAsync(storedToken.UserID, verifiedUser?.Email ?? string.Empty, true, "Email verified"); } return confirmed ? AuthenticationResult.Succeeded() : AuthenticationResult.Failed("This verification link could not be used."); } public async Task ResendVerificationEmailAsync(string email) { var cleanedEmail = Clean(email); var user = await users.GetByEmailAsync(cleanedEmail); if (user is null) { logger.LogInformation("Verification resend requested for unknown email address {Email}.", cleanedEmail); return AuthenticationResult.Succeeded(); } if (user.EmailConfirmed) { logger.LogInformation("Verification resend requested for already verified user {UserID}.", user.UserID); return AuthenticationResult.Succeeded(); } try { var token = Guid.NewGuid(); await authentication.CreateEmailVerificationTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime)); await emails.SendVerificationEmailAsync(user.Email, user.DisplayName, token); await InsertLoginAuditAsync(user.UserID, user.Email, true, "Email verification requested"); logger.LogInformation("Verification email resent to user {UserID}.", user.UserID); return AuthenticationResult.Succeeded(); } catch (Exception ex) { logger.LogError(ex, "Verification email resend failed for user {UserID}.", user.UserID); return AuthenticationResult.Failed("We could not send the verification email right now. Please try again in a few minutes."); } } public async Task ForgotPasswordAsync(ForgotPasswordViewModel model) { var email = Clean(model.Email); var user = await users.GetByEmailAsync(email); if (user is null) { logger.LogInformation("Password reset requested for unknown email address {Email}.", email); return AuthenticationResult.Succeeded(); } var token = Guid.NewGuid(); await authentication.CreatePasswordResetTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime)); await emails.SendPasswordResetEmailAsync(user.Email, user.DisplayName, token); await InsertLoginAuditAsync(user.UserID, user.Email, true, "Password reset requested"); return AuthenticationResult.Succeeded(); } public async Task ResetPasswordAsync(ResetPasswordViewModel model) { var storedToken = await authentication.GetPasswordResetTokenAsync(model.Token); if (storedToken is null) { logger.LogWarning("Password reset failed because token {Token} was not found.", model.Token); return AuthenticationResult.Failed("This password reset link is not valid."); } if (storedToken.UsedUtc.HasValue) { return AuthenticationResult.Failed("This password reset link has already been used."); } if (storedToken.ExpiryUtc < DateTime.UtcNow) { return AuthenticationResult.Failed("This password reset link has expired."); } var passwordHash = passwords.HashPassword(model.Password); var reset = await users.ResetPasswordAsync(storedToken.UserID, model.Token, passwordHash); if (reset) { var resetUser = await users.GetByIdAsync(storedToken.UserID); await InsertLoginAuditAsync(storedToken.UserID, resetUser?.Email ?? string.Empty, true, "Password reset completed"); } return reset ? AuthenticationResult.Succeeded() : AuthenticationResult.Failed("Your password could not be reset with this link."); } public async Task ChangePasswordAsync(ChangePasswordViewModel model) { var userId = CurrentUserId(); if (!userId.HasValue) { return AuthenticationResult.Failed("You need to sign in again before changing your password."); } var user = await users.GetByIdAsync(userId.Value); if (user is null) { return AuthenticationResult.Failed("Your account could not be found."); } if (!passwords.VerifyPassword(model.CurrentPassword, user.PasswordHash)) { await InsertLoginAuditAsync(user.UserID, user.Email, false, "Password change failed, bad password"); return AuthenticationResult.Failed("The current password was not recognised."); } var passwordHash = passwords.HashPassword(model.NewPassword); await users.ChangePasswordAsync(user.UserID, passwordHash); await InsertLoginAuditAsync(user.UserID, user.Email, true, "Password changed"); await LogoutAsync(); return AuthenticationResult.Succeeded(); } public async Task EnableTwoFactorAsync(int userId, string secretKey, string code) { var user = await users.GetByIdAsync(userId); if (user is null) { return TwoFactorSetupResult.Failed("Your account could not be found."); } if (!twoFactor.ValidateCode(secretKey, code, lastUsedTimeStep: null, out var matchedTimeStep)) { return TwoFactorSetupResult.Failed("The authenticator code was not recognised."); } await authentication.SaveTwoFactorAsync(userId, secretKey, null, DateTime.UtcNow); await authentication.UpdateLastUsedTimeStepAsync(userId, matchedTimeStep); await authentication.EnableTwoFactorAsync(userId); var recoveryCodes = await GenerateRecoveryCodesAsync(userId); await InsertLoginAuditAsync(userId, user.Email, true, "2FA enabled"); return TwoFactorSetupResult.Succeeded(recoveryCodes); } public async Task DisableTwoFactorAsync(int userId, string currentPassword) { var user = await users.GetByIdAsync(userId); if (user is null) { return AuthenticationResult.Failed("Your account could not be found."); } if (!passwords.VerifyPassword(currentPassword, user.PasswordHash)) { return AuthenticationResult.Failed("The current password was not recognised."); } await authentication.DisableTwoFactorAsync(userId); await InsertLoginAuditAsync(user.UserID, user.Email, true, "2FA disabled"); return AuthenticationResult.Succeeded(); } public async Task> GenerateRecoveryCodesAsync(int userId) { var recoveryCodes = twoFactor.GenerateRecoveryCodes(10); var hashes = recoveryCodes .Select(code => passwords.HashPassword(NormalizeRecoveryCode(code))) .ToArray(); await authentication.SaveRecoveryCodesAsync(userId, JsonSerializer.Serialize(hashes)); var user = await users.GetByIdAsync(userId); await InsertLoginAuditAsync(userId, user?.Email ?? string.Empty, true, "Recovery codes regenerated"); return recoveryCodes; } private async Task SignInAsync(AppUser user, bool rememberMe) { var claims = new List { new(ClaimTypes.NameIdentifier, user.UserID.ToString()), new(ClaimTypes.Name, user.DisplayName), new(ClaimTypes.Email, user.Email), new(SecurityStampClaimType, user.SecurityStamp.ToString("D")) }; 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(); private int? CurrentUserId() { var value = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier); return int.TryParse(value, out var userId) ? userId : null; } private static bool IsLockedOut(AppUser user) { return user.IsLocked && (!user.LockoutEndUtc.HasValue || user.LockoutEndUtc > DateTime.UtcNow); } private void SetPendingTwoFactor(int userId, bool rememberMe, string? returnUrl) { var session = GetSession(); session.SetInt32(PendingTwoFactorUserIdKey, userId); session.SetString(PendingTwoFactorRememberMeKey, rememberMe ? "true" : "false"); session.SetString(PendingTwoFactorReturnUrlKey, returnUrl ?? string.Empty); session.SetString(PendingTwoFactorExpiresUtcKey, DateTimeOffset.UtcNow.Add(PendingTwoFactorLifetime).ToString("O")); } private (int UserID, bool RememberMe, string? ReturnUrl)? GetPendingTwoFactor() { var session = GetSession(); var userId = session.GetInt32(PendingTwoFactorUserIdKey); var expiresValue = session.GetString(PendingTwoFactorExpiresUtcKey); if (!userId.HasValue || string.IsNullOrWhiteSpace(expiresValue) || !DateTimeOffset.TryParse(expiresValue, out var expires) || expires <= DateTimeOffset.UtcNow) { ClearPendingTwoFactor(); return null; } var rememberMe = string.Equals(session.GetString(PendingTwoFactorRememberMeKey), "true", StringComparison.OrdinalIgnoreCase); var returnUrl = session.GetString(PendingTwoFactorReturnUrlKey); return (userId.Value, rememberMe, string.IsNullOrWhiteSpace(returnUrl) ? null : returnUrl); } private void ClearPendingTwoFactor() { var session = GetSession(); session.Remove(PendingTwoFactorUserIdKey); session.Remove(PendingTwoFactorRememberMeKey); session.Remove(PendingTwoFactorReturnUrlKey); session.Remove(PendingTwoFactorExpiresUtcKey); } private async Task<(AppUser User, UserTwoFactor Settings, (int UserID, bool RememberMe, string? ReturnUrl) Pending)?> GetPendingTwoFactorContextAsync() { var pending = GetPendingTwoFactor(); if (pending is null) { return null; } var user = await users.GetByIdAsync(pending.Value.UserID); if (user is null || !user.TwoFactorEnabled) { ClearPendingTwoFactor(); return null; } var settings = await authentication.GetTwoFactorAsync(user.UserID); if (settings is null) { ClearPendingTwoFactor(); return null; } return (user, settings, pending.Value); } private async Task CompletePendingTwoFactorSignInAsync(AppUser user, bool rememberMe, string auditReason) { var updatedUser = await users.UpdateLoginSuccessAsync(user.UserID) ?? user; await InsertLoginAuditAsync(user.UserID, user.Email, true, auditReason); await SignInAsync(updatedUser, rememberMe); ClearPendingTwoFactor(); return AuthenticationResult.Succeeded(); } private ISession GetSession() { return httpContextAccessor.HttpContext?.Session ?? throw new InvalidOperationException("No active session is available."); } private async Task TryUseRecoveryCodeAsync(int userId, string recoveryCode) { var stored = await authentication.GetRecoveryCodesAsync(userId); if (string.IsNullOrWhiteSpace(stored)) { return false; } var hashes = JsonSerializer.Deserialize>(stored) ?? new List(); var normalized = NormalizeRecoveryCode(recoveryCode); for (var i = 0; i < hashes.Count; i++) { if (!passwords.VerifyPassword(normalized, hashes[i])) { continue; } hashes.RemoveAt(i); await authentication.SaveRecoveryCodesAsync(userId, JsonSerializer.Serialize(hashes)); return true; } return false; } private static string NormalizeRecoveryCode(string code) { return code.Trim().Replace(" ", string.Empty).Replace("-", string.Empty).ToUpperInvariant(); } }