PlotDirector/PlotLine/Services/AuthServices.cs

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();
}
}