using System.Security.Claims; using System.Net; using System.Net.Mail; using System.Net.Mime; 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, IEmailService emails, IHttpContextAccessor httpContextAccessor, ILogger logger) : IAuthService { private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24); 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); 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, "User not found"); return AuthenticationResult.Failed("The email address or password was not recognised."); } if (!user.EmailConfirmed) { await InsertLoginAuditAsync(user.UserID, email, false, "Email not verified"); return AuthenticationResult.Failed("Please verify your email address before signing in."); } 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 CompleteTwoFactorLoginAsync(TwoFactorViewModel model) => throw new NotImplementedException("Two-factor login will be implemented in a later phase."); 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); 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); 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); 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); return reset ? AuthenticationResult.Succeeded() : AuthenticationResult.Failed("Your password could not be reset with this link."); } public Task ChangePasswordAsync(ChangePasswordViewModel model) => throw new NotImplementedException("Password changes will be implemented in a later phase."); public Task EnableTwoFactorAsync(int userId) => throw new NotImplementedException("Two-factor setup will be implemented in a later phase."); public Task DisableTwoFactorAsync(int userId) => throw new NotImplementedException("Two-factor setup will be implemented in a later phase."); public Task> 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 { 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(); }