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

447 lines
18 KiB
C#

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<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 EmailService(
IOptions<EmailSettings> emailOptions,
IEmailQueueRepository emailQueue,
IHttpContextAccessor httpContextAccessor,
ILogger<EmailService> 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 = $"""
<!doctype html>
<html>
<body style="font-family:Segoe UI,Arial,sans-serif;background:#f7f1e8;margin:0;padding:32px;color:#251c17;">
<div style="max-width:620px;margin:0 auto;background:#fffaf2;border:1px solid #e4d4bd;border-radius:10px;padding:28px;">
<p style="margin:0 0 8px;text-transform:uppercase;letter-spacing:.08em;color:#7a5b3b;font-size:12px;font-weight:700;">PlotWeaver Studio</p>
<h1 style="margin:0 0 16px;font-size:26px;">{safeTitle}</h1>
<p>Hello {safeName},</p>
<p>{safeIntro}</p>
<p style="margin:28px 0;">
<a href="{safeLink}" style="display:inline-block;background:#2f6f63;color:#fff;text-decoration:none;padding:12px 18px;border-radius:6px;font-weight:700;">{safeButton}</a>
</p>
<p>If the button does not work, copy and paste this link into your browser:</p>
<p style="word-break:break-all;color:#4d625c;">{safeLink}</p>
<p style="color:#6f6256;font-size:14px;">{safeFootnote}</p>
</div>
</body>
</html>
""";
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<EmailSettings> emailOptions,
ILogger<QueuedEmailSender> 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<AuthService> logger) : IAuthService
{
private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24);
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: 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<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 (!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<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model)
=> throw new NotImplementedException("Two-factor login will be implemented in a later phase.");
public async Task<AuthenticationResult> 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<AuthenticationResult> 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<AuthenticationResult> 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<AuthenticationResult> 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<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();
}