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, 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 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 SendAsync(email, displayName, "Verify your PlotWeaver Studio account", html); } public Task SendPasswordResetEmailAsync(string email, string displayName, Guid token) { var link = BuildAccountUrl("ResetPassword", token); var 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 SendAsync(email, displayName, "Reset your PlotWeaver Studio password", html); } private async Task SendAsync(string toEmail, string displayName, string subject, string html) { if (string.IsNullOrWhiteSpace(_settings.SmtpServer) || string.IsNullOrWhiteSpace(_settings.Username) || string.IsNullOrWhiteSpace(_settings.Password) || string.IsNullOrWhiteSpace(_settings.FromAddress)) { logger.LogWarning("Email '{Subject}' for {Email} could not be sent because SMTP settings are incomplete.", subject, toEmail); throw new InvalidOperationException("Email settings are not configured."); } try { using var message = new MailMessage { From = new MailAddress(_settings.FromAddress, CleanDisplayName(_settings.FromName)), Subject = subject, Body = html, IsBodyHtml = true }; message.To.Add(new MailAddress(toEmail, CleanDisplayName(displayName))); message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(StripHtml(html), null, MediaTypeNames.Text.Plain)); using var client = new SmtpClient(_settings.SmtpServer, _settings.Port) { EnableSsl = _settings.EnableSsl, Credentials = new NetworkCredential(_settings.Username, _settings.Password) }; await client.SendMailAsync(message); logger.LogInformation("Email '{Subject}' sent to {Email}.", subject, toEmail); } catch (Exception ex) { logger.LogError(ex, "Email '{Subject}' could not be sent to {Email}.", subject, toEmail); throw; } } 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 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); return $"""

PlotWeaver Studio

{safeTitle}

Hello {safeName},

{safeIntro}

{safeButton}

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

{safeLink}

{safeFootnote}

"""; } private static string StripHtml(string html) { return WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ")); } 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, IOptions emailOptions, IHttpContextAccessor httpContextAccessor, ILogger logger) : IAuthService { private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24); private readonly EmailSettings _emailSettings = emailOptions.Value; public async Task RegisterAsync(RegisterViewModel model) { var email = Clean(model.Email); var displayName = Clean(model.DisplayName); if (!EmailIsConfigured()) { logger.LogWarning("Registration could not continue because SMTP settings are incomplete."); return RegistrationResult.Failed("Email sending is not configured yet. Please try again later."); } 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); if (!EmailIsConfigured()) { logger.LogWarning("Password reset email could not be sent because SMTP settings are incomplete."); return AuthenticationResult.Failed("Password reset email could not be sent right now."); } 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(); } if (!EmailIsConfigured()) { logger.LogWarning("Verification resend for user {UserID} could not continue because SMTP settings are incomplete.", user.UserID); return AuthenticationResult.Failed("We could not send the verification email right now. Please try again in a few minutes."); } 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(); private bool EmailIsConfigured() { return !string.IsNullOrWhiteSpace(_emailSettings.SmtpServer) && !string.IsNullOrWhiteSpace(_emailSettings.Username) && !string.IsNullOrWhiteSpace(_emailSettings.Password) && !string.IsNullOrWhiteSpace(_emailSettings.FromAddress); } }