824 lines
32 KiB
C#
824 lines
32 KiB
C#
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<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,
|
|
IConfiguration configuration,
|
|
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 PlotDirector account",
|
|
displayName,
|
|
"Please verify your email address to finish setting up your PlotDirector account.",
|
|
"Verify account",
|
|
link,
|
|
"This link expires in 24 hours.");
|
|
|
|
return QueueAsync(email, displayName, "Verify your PlotDirector account", plainText, html);
|
|
}
|
|
|
|
public Task SendPasswordResetEmailAsync(string email, string displayName, Guid token)
|
|
{
|
|
var link = BuildAccountUrl("ResetPassword", token);
|
|
var (plainText, html) = BuildTemplate(
|
|
"Reset your PlotDirector password",
|
|
displayName,
|
|
"Use the link below to choose a new password for your PlotDirector 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 PlotDirector password", plainText, html);
|
|
}
|
|
|
|
public Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail)
|
|
{
|
|
if (string.Equals(request.UserEmail, actorEmail, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
var body = $"""
|
|
Hi {DisplayName(request.UserDisplayName)},
|
|
|
|
There is a new reply on your PlotDirector feature request:
|
|
|
|
"{request.Title}"
|
|
|
|
{messagePreview}
|
|
|
|
You can view and reply here:
|
|
{link}
|
|
|
|
Thanks,
|
|
The PlotDirector team
|
|
""";
|
|
|
|
return QueueAsync(
|
|
request.UserEmail ?? string.Empty,
|
|
DisplayName(request.UserDisplayName),
|
|
$"PlotDirector feature request update: {request.Title}",
|
|
body,
|
|
html: null);
|
|
}
|
|
|
|
public Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail)
|
|
{
|
|
if (string.Equals(request.UserEmail, actorEmail, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
var body = $"""
|
|
Hi {DisplayName(request.UserDisplayName)},
|
|
|
|
The status of your feature request has changed:
|
|
|
|
"{request.Title}"
|
|
|
|
New status: {request.Status}
|
|
|
|
{request.StatusExplanation}
|
|
|
|
You can view the request here:
|
|
{link}
|
|
|
|
Thanks,
|
|
The PlotDirector team
|
|
""";
|
|
|
|
return QueueAsync(
|
|
request.UserEmail ?? string.Empty,
|
|
DisplayName(request.UserDisplayName),
|
|
$"PlotDirector feature request status changed: {request.Title}",
|
|
body,
|
|
html: null);
|
|
}
|
|
|
|
public async Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail)
|
|
{
|
|
var adminEmails = configuration.GetSection("Admin:AllowedEmails").Get<string[]>() ?? [];
|
|
foreach (var adminEmail in adminEmails
|
|
.Where(email => !string.IsNullOrWhiteSpace(email))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Where(email => !string.Equals(email, actorEmail, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
var body = $"""
|
|
A user replied to a PlotDirector feature request:
|
|
|
|
"{request.Title}"
|
|
|
|
User: {request.UserDisplayName} ({request.UserEmail})
|
|
|
|
{messagePreview}
|
|
|
|
View the request here:
|
|
{link}
|
|
""";
|
|
|
|
await QueueAsync(
|
|
adminEmail,
|
|
"PlotDirector admin",
|
|
$"PlotDirector feature request reply: {request.Title}",
|
|
body,
|
|
html: null);
|
|
}
|
|
}
|
|
|
|
private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string? html)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(toEmail))
|
|
{
|
|
return;
|
|
}
|
|
|
|
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;">PlotDirector</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) ? "PlotDirector" : value.Trim();
|
|
}
|
|
|
|
private static string DisplayName(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? "there" : value.Trim();
|
|
}
|
|
|
|
private string GetFromAddress()
|
|
{
|
|
return string.IsNullOrWhiteSpace(_settings.FromAddress)
|
|
? "hello@plotdirector.com"
|
|
: _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) ? "PlotDirector" : value.Trim();
|
|
}
|
|
}
|
|
|
|
public sealed class AuthService(
|
|
IUserRepository users,
|
|
IAuthenticationRepository authentication,
|
|
IPasswordService passwords,
|
|
ITwoFactorService twoFactor,
|
|
IEmailService emails,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
ILogger<AuthService> 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 = "PlotLine.SecurityStamp";
|
|
private const string PendingTwoFactorUserIdKey = "PlotLine.PendingTwoFactor.UserID";
|
|
private const string PendingTwoFactorRememberMeKey = "PlotLine.PendingTwoFactor.RememberMe";
|
|
private const string PendingTwoFactorReturnUrlKey = "PlotLine.PendingTwoFactor.ReturnUrl";
|
|
private const string PendingTwoFactorExpiresUtcKey = "PlotLine.PendingTwoFactor.ExpiresUtc";
|
|
|
|
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);
|
|
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<AuthenticationResult> 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<AuthenticationResult> 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<AuthenticationResult> 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<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);
|
|
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<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);
|
|
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<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);
|
|
await InsertLoginAuditAsync(user.UserID, user.Email, true, "Password reset requested");
|
|
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);
|
|
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<AuthenticationResult> 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<TwoFactorSetupResult> 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<AuthenticationResult> 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<IReadOnlyList<string>> 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<Claim>
|
|
{
|
|
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<AuthenticationResult> 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<bool> TryUseRecoveryCodeAsync(int userId, string recoveryCode)
|
|
{
|
|
var stored = await authentication.GetRecoveryCodesAsync(userId);
|
|
if (string.IsNullOrWhiteSpace(stored))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var hashes = JsonSerializer.Deserialize<List<string>>(stored) ?? new List<string>();
|
|
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();
|
|
}
|
|
}
|