Phase 4E AuthService 2FA

This commit is contained in:
Nick Beckley 2026-06-06 20:04:19 +01:00
parent 2f4590b681
commit 6895009223
19 changed files with 1109 additions and 21 deletions

View File

@ -63,6 +63,11 @@ public sealed class AccountController(IAuthService authService) : Controller
}
var result = await authService.LoginAsync(model);
if (result.RequiresTwoFactor)
{
return RedirectToAction(nameof(TwoFactor), new { returnUrl = model.ReturnUrl });
}
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Login could not be completed.");
@ -223,4 +228,88 @@ public sealed class AccountController(IAuthService authService) : Controller
TempData["AuthMessage"] = "If an unverified account exists for that email address, a verification email will be sent shortly.";
return View(new ResendVerificationEmailViewModel());
}
[HttpGet]
[AllowAnonymous]
public IActionResult TwoFactor(string? returnUrl = null)
{
if (!authService.HasPendingTwoFactorLogin())
{
return RedirectToAction(nameof(Login));
}
return View(new TwoFactorCodeViewModel
{
ReturnUrl = returnUrl ?? authService.GetPendingTwoFactorReturnUrl()
});
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> TwoFactor(TwoFactorCodeViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var result = await authService.CompleteTwoFactorLoginAsync(model);
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Two-factor login could not be completed.");
return View(model);
}
if (!string.IsNullOrWhiteSpace(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl))
{
return LocalRedirect(model.ReturnUrl);
}
return RedirectToAction("Index", "Home");
}
[HttpGet]
[AllowAnonymous]
public IActionResult TwoFactorRecovery(string? returnUrl = null)
{
if (!authService.HasPendingTwoFactorLogin())
{
return RedirectToAction(nameof(Login));
}
return View(new TwoFactorRecoveryViewModel
{
ReturnUrl = returnUrl ?? authService.GetPendingTwoFactorReturnUrl()
});
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> TwoFactorRecovery(TwoFactorRecoveryViewModel model)
{
if (!authService.HasPendingTwoFactorLogin())
{
return RedirectToAction(nameof(Login));
}
if (!ModelState.IsValid)
{
return View(model);
}
var result = await authService.CompleteTwoFactorRecoveryLoginAsync(model);
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Recovery code login could not be completed.");
return View(model);
}
if (!string.IsNullOrWhiteSpace(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl))
{
return LocalRedirect(model.ReturnUrl);
}
return RedirectToAction("Index", "Home");
}
}

View File

@ -0,0 +1,123 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Data;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize]
public sealed class ManageAccountController(
ICurrentUserService currentUser,
IUserRepository users,
IAuthService authService,
ITwoFactorService twoFactorService) : Controller
{
[HttpGet]
public async Task<IActionResult> Index()
{
var user = await GetCurrentUserAsync();
if (user is null)
{
return RedirectToAction("Login", "Account");
}
return View(user);
}
[HttpGet]
public async Task<IActionResult> EnableTwoFactor()
{
var user = await GetCurrentUserAsync();
if (user is null)
{
return RedirectToAction("Login", "Account");
}
var secret = twoFactorService.GenerateSecretKey();
var uri = twoFactorService.GenerateQrCodeUri(user.Email, secret);
return View(new EnableTwoFactorViewModel
{
SecretKey = secret,
QrCodeUri = uri,
QrCodeImageDataUri = twoFactorService.GenerateQrCodeDataUri(uri)
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EnableTwoFactor(EnableTwoFactorViewModel model)
{
if (!ModelState.IsValid)
{
model.QrCodeUri = twoFactorService.GenerateQrCodeUri(currentUser.Email ?? string.Empty, model.SecretKey);
model.QrCodeImageDataUri = twoFactorService.GenerateQrCodeDataUri(model.QrCodeUri);
return View(model);
}
var result = await authService.EnableTwoFactorAsync(currentUser.UserId!.Value, model.SecretKey, model.Code);
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Two-factor authentication could not be enabled.");
model.QrCodeUri = twoFactorService.GenerateQrCodeUri(currentUser.Email ?? string.Empty, model.SecretKey);
model.QrCodeImageDataUri = twoFactorService.GenerateQrCodeDataUri(model.QrCodeUri);
return View(model);
}
TempData["RecoveryCodes"] = JsonSerializer.Serialize(result.RecoveryCodes);
TempData["AuthMessage"] = "Two-factor authentication has been enabled.";
return RedirectToAction(nameof(RecoveryCodes));
}
[HttpGet]
public IActionResult DisableTwoFactor()
{
return View(new DisableTwoFactorViewModel());
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DisableTwoFactor(DisableTwoFactorViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var result = await authService.DisableTwoFactorAsync(currentUser.UserId!.Value, model.CurrentPassword);
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Two-factor authentication could not be disabled.");
return View(model);
}
TempData["AuthMessage"] = "Two-factor authentication has been disabled.";
return RedirectToAction(nameof(Index));
}
[HttpGet]
public IActionResult RecoveryCodes()
{
var codes = TempData["RecoveryCodes"] is string json
? JsonSerializer.Deserialize<IReadOnlyList<string>>(json) ?? Array.Empty<string>()
: Array.Empty<string>();
return View(new RecoveryCodesViewModel { RecoveryCodes = codes });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegenerateRecoveryCodes()
{
var codes = await authService.GenerateRecoveryCodesAsync(currentUser.UserId!.Value);
TempData["RecoveryCodes"] = JsonSerializer.Serialize(codes);
TempData["AuthMessage"] = "New recovery codes have been generated.";
return RedirectToAction(nameof(RecoveryCodes));
}
private async Task<PlotLine.Models.AppUser?> GetCurrentUserAsync()
{
return currentUser.UserId is int userId ? await users.GetByIdAsync(userId) : null;
}
}

View File

@ -24,7 +24,12 @@ public interface IAuthenticationRepository
Task<UserPasswordResetToken> CreatePasswordResetTokenAsync(int userId, Guid token, DateTime expiryUtc);
Task<UserPasswordResetToken?> GetPasswordResetTokenAsync(Guid token);
Task<UserTwoFactor?> GetTwoFactorAsync(int userId);
Task<UserTwoFactor> SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes);
Task<UserTwoFactor> SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes, DateTime? confirmedUtc);
Task<AppUser?> EnableTwoFactorAsync(int userId);
Task<AppUser?> DisableTwoFactorAsync(int userId);
Task<UserTwoFactor?> SaveRecoveryCodesAsync(int userId, string recoveryCodes);
Task<string?> GetRecoveryCodesAsync(int userId);
Task<bool> UpdateLastUsedTimeStepAsync(int userId, long lastUsedTimeStep);
Task<long> InsertLoginAuditAsync(UserLoginAudit audit);
}
@ -154,17 +159,62 @@ public sealed class AuthenticationRepository(ISqlConnectionFactory connectionFac
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<UserTwoFactor>(
"dbo.User_GetTwoFactor",
"dbo.UserTwoFactor_Get",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<UserTwoFactor> SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes)
public async Task<UserTwoFactor> SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes, DateTime? confirmedUtc)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserTwoFactor>(
"dbo.User_SaveTwoFactor",
new { UserID = userId, SecretKey = secretKey, RecoveryCodes = recoveryCodes },
"dbo.UserTwoFactor_Save",
new { UserID = userId, SecretKey = secretKey, RecoveryCodes = recoveryCodes, ConfirmedUtc = confirmedUtc },
commandType: CommandType.StoredProcedure);
}
public async Task<AppUser?> EnableTwoFactorAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<AppUser>(
"dbo.UserTwoFactor_Enable",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<AppUser?> DisableTwoFactorAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<AppUser>(
"dbo.UserTwoFactor_Disable",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<UserTwoFactor?> SaveRecoveryCodesAsync(int userId, string recoveryCodes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<UserTwoFactor>(
"dbo.UserTwoFactor_SaveRecoveryCodes",
new { UserID = userId, RecoveryCodes = recoveryCodes },
commandType: CommandType.StoredProcedure);
}
public async Task<string?> GetRecoveryCodesAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<string?>(
"dbo.UserTwoFactor_GetRecoveryCodes",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> UpdateLastUsedTimeStepAsync(int userId, long lastUsedTimeStep)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.UserTwoFactor_UpdateLastUsedTimeStep",
new { UserID = userId, LastUsedTimeStep = lastUsedTimeStep },
commandType: CommandType.StoredProcedure);
}

View File

@ -40,6 +40,8 @@ public sealed class UserTwoFactor
public string SecretKey { get; set; } = string.Empty;
public string? RecoveryCodes { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime? ConfirmedUtc { get; set; }
public long? LastUsedTimeStep { get; set; }
}
public sealed class UserLoginAudit

View File

@ -9,6 +9,8 @@ public class AuthenticationResult
public static AuthenticationResult Succeeded() => new() { Success = true };
public static AuthenticationResult Failed(string errorMessage) => new() { ErrorMessage = errorMessage };
public static AuthenticationResult TwoFactorRequired() => new() { RequiresTwoFactor = true };
}
public sealed class RegistrationResult : AuthenticationResult
@ -19,3 +21,13 @@ public sealed class RegistrationResult : AuthenticationResult
public new static RegistrationResult Failed(string errorMessage) => new() { ErrorMessage = errorMessage };
}
public sealed class TwoFactorSetupResult : AuthenticationResult
{
public IReadOnlyList<string> RecoveryCodes { get; init; } = Array.Empty<string>();
public static TwoFactorSetupResult Succeeded(IReadOnlyList<string> recoveryCodes)
=> new() { Success = true, RecoveryCodes = recoveryCodes };
public new static TwoFactorSetupResult Failed(string errorMessage) => new() { ErrorMessage = errorMessage };
}

View File

@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.79" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
<PackageReference Include="QRCoder" Version="1.6.0" />
</ItemGroup>
<ItemGroup>

View File

@ -18,6 +18,14 @@ public class Program
builder.Services.AddControllersWithViews();
builder.Services.AddHttpContextAccessor();
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(10);
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
});
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
@ -93,7 +101,7 @@ public class Program
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IPasswordService, PasswordService>();
builder.Services.AddScoped<ITwoFactorService>(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase."));
builder.Services.AddScoped<ITwoFactorService, TwoFactorService>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IQueuedEmailSender, QueuedEmailSender>();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
@ -111,6 +119,7 @@ public class Program
app.UseStaticFiles();
app.UseRouting();
app.UseSession();
app.UseAuthentication();
app.UseAuthorization();

View File

@ -7,15 +7,18 @@ public interface IAuthService
{
Task<RegistrationResult> RegisterAsync(RegisterViewModel model);
Task<AuthenticationResult> LoginAsync(LoginViewModel model);
Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model);
bool HasPendingTwoFactorLogin();
string? GetPendingTwoFactorReturnUrl();
Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorCodeViewModel model);
Task<AuthenticationResult> CompleteTwoFactorRecoveryLoginAsync(TwoFactorRecoveryViewModel model);
Task LogoutAsync();
Task<AuthenticationResult> VerifyEmailAsync(Guid token);
Task<AuthenticationResult> ResendVerificationEmailAsync(string email);
Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model);
Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model);
Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model);
Task<AuthenticationResult> EnableTwoFactorAsync(int userId);
Task<AuthenticationResult> DisableTwoFactorAsync(int userId);
Task<TwoFactorSetupResult> EnableTwoFactorAsync(int userId, string secretKey, string code);
Task<AuthenticationResult> DisableTwoFactorAsync(int userId, string currentPassword);
Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(int userId);
}
@ -30,6 +33,8 @@ public interface ITwoFactorService
string GenerateSecretKey();
string GenerateQrCodeUri(string email, string secretKey);
bool ValidateCode(string secretKey, string code);
bool ValidateCode(string secretKey, string code, long? lastUsedTimeStep, out long matchedTimeStep);
string? GenerateQrCodeDataUri(string qrCodeUri);
IReadOnlyList<string> GenerateRecoveryCodes(int count);
}

View File

@ -2,6 +2,7 @@ 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;
@ -227,11 +228,17 @@ 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 const string PendingTwoFactorUserIdKey = "PlotWeaver.PendingTwoFactor.UserID";
private const string PendingTwoFactorRememberMeKey = "PlotWeaver.PendingTwoFactor.RememberMe";
private const string PendingTwoFactorReturnUrlKey = "PlotWeaver.PendingTwoFactor.ReturnUrl";
private const string PendingTwoFactorExpiresUtcKey = "PlotWeaver.PendingTwoFactor.ExpiresUtc";
public async Task<RegistrationResult> RegisterAsync(RegisterViewModel model)
{
@ -277,6 +284,13 @@ public sealed class AuthService(
return AuthenticationResult.Failed("The email address or password was not recognised.");
}
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);
@ -284,6 +298,10 @@ public sealed class AuthService(
return AuthenticationResult.Succeeded();
}
public bool HasPendingTwoFactorLogin() => GetPendingTwoFactor() is not null;
public string? GetPendingTwoFactorReturnUrl() => GetPendingTwoFactor()?.ReturnUrl;
public async Task LogoutAsync()
{
var httpContext = httpContextAccessor.HttpContext;
@ -293,8 +311,40 @@ public sealed class AuthService(
}
}
public Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model)
=> throw new NotImplementedException("Two-factor login will be implemented in a later phase.");
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, "Invalid two-factor code");
return AuthenticationResult.Failed("The authentication code was not recognised.");
}
return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "Two-factor login successful");
}
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, "Invalid two-factor recovery code");
return AuthenticationResult.Failed("The recovery code was not recognised.");
}
return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "Two-factor recovery login successful");
}
public async Task<AuthenticationResult> VerifyEmailAsync(Guid token)
{
@ -397,14 +447,54 @@ public sealed class AuthService(
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 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.");
}
public Task<AuthenticationResult> DisableTwoFactorAsync(int userId)
=> throw new NotImplementedException("Two-factor setup will be implemented in a later phase.");
if (!twoFactor.ValidateCode(secretKey, code, lastUsedTimeStep: null, out var matchedTimeStep))
{
return TwoFactorSetupResult.Failed("The authenticator code was not recognised.");
}
public Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(int userId)
=> throw new NotImplementedException("Recovery codes will be implemented in a later phase.");
await authentication.SaveTwoFactorAsync(userId, secretKey, null, DateTime.UtcNow);
await authentication.UpdateLastUsedTimeStepAsync(userId, matchedTimeStep);
await authentication.EnableTwoFactorAsync(userId);
var recoveryCodes = await GenerateRecoveryCodesAsync(userId);
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);
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));
return recoveryCodes;
}
private async Task SignInAsync(AppUser user, bool rememberMe)
{
@ -443,4 +533,110 @@ public sealed class AuthService(
private static string Clean(string value) => value.Trim();
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();
}
}

View File

@ -0,0 +1,181 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using QRCoder;
namespace PlotLine.Services;
public sealed class TwoFactorService(ILogger<TwoFactorService> logger) : ITwoFactorService
{
private const string Issuer = "PlotWeaver Studio";
private const int CodeDigits = 6;
private const int TimeStepSeconds = 30;
private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
public string GenerateSecretKey()
{
var bytes = RandomNumberGenerator.GetBytes(20);
return ToBase32(bytes);
}
public string GenerateQrCodeUri(string email, string secretKey)
{
var issuer = Uri.EscapeDataString(Issuer);
var account = Uri.EscapeDataString($"{Issuer}:{email}");
return $"otpauth://totp/{account}?secret={secretKey}&issuer={issuer}";
}
public bool ValidateCode(string secretKey, string code)
{
return ValidateCode(secretKey, code, lastUsedTimeStep: null, out _);
}
public bool ValidateCode(string secretKey, string code, long? lastUsedTimeStep, out long matchedTimeStep)
{
matchedTimeStep = 0;
var cleanCode = CleanCode(code);
if (cleanCode.Length != CodeDigits || cleanCode.Any(c => !char.IsDigit(c)))
{
return false;
}
var secretBytes = FromBase32(secretKey);
var currentStep = GetCurrentTimeStep();
for (var offset = -1; offset <= 1; offset++)
{
var step = currentStep + offset;
if (lastUsedTimeStep.HasValue && step <= lastUsedTimeStep.Value)
{
continue;
}
if (GenerateTotp(secretBytes, step) == cleanCode)
{
matchedTimeStep = step;
return true;
}
}
return false;
}
public string? GenerateQrCodeDataUri(string qrCodeUri)
{
try
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(qrCodeUri, QRCodeGenerator.ECCLevel.Q);
var qrCode = new PngByteQRCode(data);
var bytes = qrCode.GetGraphic(8);
return $"data:image/png;base64,{Convert.ToBase64String(bytes)}";
}
catch (Exception ex)
{
logger.LogWarning(ex, "QR code generation failed for two-factor setup.");
return null;
}
}
public IReadOnlyList<string> GenerateRecoveryCodes(int count)
{
var codes = new List<string>(count);
for (var i = 0; i < count; i++)
{
codes.Add(GenerateRecoveryCode());
}
return codes;
}
private static long GetCurrentTimeStep()
{
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() / TimeStepSeconds;
}
private static string GenerateTotp(byte[] secretBytes, long timeStep)
{
Span<byte> counter = stackalloc byte[8];
BinaryPrimitives.WriteInt64BigEndian(counter, timeStep);
using var hmac = new HMACSHA1(secretBytes);
var hash = hmac.ComputeHash(counter.ToArray());
var offset = hash[^1] & 0x0f;
var binaryCode = ((hash[offset] & 0x7f) << 24)
| ((hash[offset + 1] & 0xff) << 16)
| ((hash[offset + 2] & 0xff) << 8)
| (hash[offset + 3] & 0xff);
var otp = binaryCode % 1_000_000;
return otp.ToString("D6");
}
private static string GenerateRecoveryCode()
{
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var bytes = RandomNumberGenerator.GetBytes(10);
var builder = new StringBuilder(10);
for (var i = 0; i < 10; i++)
{
builder.Append(alphabet[bytes[i] % alphabet.Length]);
}
return builder.ToString();
}
private static string ToBase32(byte[] bytes)
{
var output = new StringBuilder((bytes.Length + 4) / 5 * 8);
var buffer = 0;
var bitsLeft = 0;
foreach (var b in bytes)
{
buffer = (buffer << 8) | b;
bitsLeft += 8;
while (bitsLeft >= 5)
{
output.Append(Base32Alphabet[(buffer >> (bitsLeft - 5)) & 31]);
bitsLeft -= 5;
}
}
if (bitsLeft > 0)
{
output.Append(Base32Alphabet[(buffer << (5 - bitsLeft)) & 31]);
}
return output.ToString();
}
private static byte[] FromBase32(string value)
{
var clean = value.Trim().Replace(" ", string.Empty).TrimEnd('=').ToUpperInvariant();
var bytes = new List<byte>();
var buffer = 0;
var bitsLeft = 0;
foreach (var c in clean)
{
var index = Base32Alphabet.IndexOf(c);
if (index < 0)
{
throw new FormatException("The two-factor secret is not valid Base32.");
}
buffer = (buffer << 5) | index;
bitsLeft += 5;
if (bitsLeft >= 8)
{
bytes.Add((byte)((buffer >> (bitsLeft - 8)) & 255));
bitsLeft -= 8;
}
}
return bytes.ToArray();
}
private static string CleanCode(string code)
{
return code.Trim().Replace(" ", string.Empty).Replace("-", string.Empty);
}
}

View File

@ -0,0 +1,155 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.UserTwoFactor', N'ConfirmedUtc') IS NULL
ALTER TABLE dbo.UserTwoFactor ADD ConfirmedUtc datetime2 NULL;
GO
IF COL_LENGTH(N'dbo.UserTwoFactor', N'LastUsedTimeStep') IS NULL
ALTER TABLE dbo.UserTwoFactor ADD LastUsedTimeStep bigint NULL;
GO
CREATE OR ALTER PROCEDURE dbo.UserTwoFactor_Get
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT UserID, SecretKey, RecoveryCodes, CreatedUtc, ConfirmedUtc, LastUsedTimeStep
FROM dbo.UserTwoFactor
WHERE UserID = @UserID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserTwoFactor_Save
@UserID int,
@SecretKey nvarchar(500),
@RecoveryCodes nvarchar(max) = NULL,
@ConfirmedUtc datetime2 = NULL
AS
BEGIN
SET NOCOUNT ON;
MERGE dbo.UserTwoFactor AS target
USING (SELECT @UserID AS UserID, @SecretKey AS SecretKey, @RecoveryCodes AS RecoveryCodes, @ConfirmedUtc AS ConfirmedUtc) AS source
ON target.UserID = source.UserID
WHEN MATCHED THEN
UPDATE SET SecretKey = source.SecretKey,
RecoveryCodes = source.RecoveryCodes,
ConfirmedUtc = source.ConfirmedUtc,
LastUsedTimeStep = NULL
WHEN NOT MATCHED THEN
INSERT (UserID, SecretKey, RecoveryCodes, ConfirmedUtc)
VALUES (source.UserID, source.SecretKey, source.RecoveryCodes, source.ConfirmedUtc);
EXEC dbo.UserTwoFactor_Get @UserID = @UserID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserTwoFactor_Enable
@UserID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.UserTwoFactor
SET ConfirmedUtc = COALESCE(ConfirmedUtc, SYSUTCDATETIME())
WHERE UserID = @UserID;
UPDATE dbo.AppUser
SET TwoFactorEnabled = 1,
UpdatedUtc = SYSUTCDATETIME()
WHERE UserID = @UserID;
EXEC dbo.User_GetById @UserID = @UserID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserTwoFactor_Disable
@UserID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.AppUser
SET TwoFactorEnabled = 0,
UpdatedUtc = SYSUTCDATETIME()
WHERE UserID = @UserID;
DELETE FROM dbo.UserTwoFactor
WHERE UserID = @UserID;
EXEC dbo.User_GetById @UserID = @UserID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserTwoFactor_SaveRecoveryCodes
@UserID int,
@RecoveryCodes nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.UserTwoFactor
SET RecoveryCodes = @RecoveryCodes
WHERE UserID = @UserID;
EXEC dbo.UserTwoFactor_Get @UserID = @UserID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserTwoFactor_GetRecoveryCodes
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT RecoveryCodes
FROM dbo.UserTwoFactor
WHERE UserID = @UserID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserTwoFactor_UpdateLastUsedTimeStep
@UserID int,
@LastUsedTimeStep bigint
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.UserTwoFactor
SET LastUsedTimeStep = @LastUsedTimeStep
WHERE UserID = @UserID
AND (LastUsedTimeStep IS NULL OR LastUsedTimeStep < @LastUsedTimeStep);
SELECT CAST(CASE WHEN @@ROWCOUNT = 1 THEN 1 ELSE 0 END AS bit);
END;
GO
CREATE OR ALTER PROCEDURE dbo.User_GetTwoFactor
@UserID int
AS
BEGIN
SET NOCOUNT ON;
EXEC dbo.UserTwoFactor_Get @UserID = @UserID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.User_SaveTwoFactor
@UserID int,
@SecretKey nvarchar(500),
@RecoveryCodes nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
EXEC dbo.UserTwoFactor_Save
@UserID = @UserID,
@SecretKey = @SecretKey,
@RecoveryCodes = @RecoveryCodes,
@ConfirmedUtc = NULL;
END;
GO

View File

@ -79,17 +79,49 @@ public sealed class AccountStatusViewModel
public string Message { get; set; } = string.Empty;
}
public sealed class TwoFactorViewModel
public sealed class TwoFactorCodeViewModel
{
[Required(ErrorMessage = "Enter your authentication code.")]
[Required(ErrorMessage = "Enter the code from your authenticator app.")]
[StringLength(20, ErrorMessage = "Authentication code is too long.")]
public string Code { get; set; } = string.Empty;
public string? ReturnUrl { get; set; }
}
public bool RememberMachine { get; set; }
public sealed class TwoFactorRecoveryViewModel
{
[Required(ErrorMessage = "Enter a recovery code.")]
[StringLength(20, ErrorMessage = "Recovery code is too long.")]
public string RecoveryCode { get; set; } = string.Empty;
public bool RememberMe { get; set; }
public string? ReturnUrl { get; set; }
}
public sealed class EnableTwoFactorViewModel
{
[Required(ErrorMessage = "Authenticator secret is missing.")]
public string SecretKey { get; set; } = string.Empty;
public string QrCodeUri { get; set; } = string.Empty;
public string? QrCodeImageDataUri { get; set; }
[Required(ErrorMessage = "Enter the code from your authenticator app.")]
[StringLength(20, ErrorMessage = "Authentication code is too long.")]
public string Code { get; set; } = string.Empty;
}
public sealed class DisableTwoFactorViewModel
{
[Required(ErrorMessage = "Enter your current password.")]
[DataType(DataType.Password)]
public string CurrentPassword { get; set; } = string.Empty;
}
public sealed class RecoveryCodesViewModel
{
public IReadOnlyList<string> RecoveryCodes { get; set; } = Array.Empty<string>();
public bool HasNewCodes => RecoveryCodes.Any();
}
public sealed class ChangePasswordViewModel

View File

@ -0,0 +1,39 @@
@model TwoFactorCodeViewModel
@{
ViewData["Title"] = "Two-factor authentication";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Two-factor authentication</h1>
<p class="lead-text">Enter the current 6-digit code from your authenticator app.</p>
</div>
</div>
<section class="edit-panel">
<form asp-action="TwoFactor" method="post" class="asset-create-form">
<input asp-for="ReturnUrl" type="hidden" />
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Code">Authenticator code</label>
<input class="form-control" asp-for="Code" autocomplete="one-time-code" />
<span class="text-danger" asp-validation-for="Code"></span>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Continue</button>
<a class="btn btn-outline-secondary" asp-action="Login">Back to Login</a>
</div>
<p class="mt-3 mb-0">
<a asp-action="TwoFactorRecovery" asp-route-returnUrl="@Model.ReturnUrl">Can't access your authenticator app? Use a recovery code.</a>
</p>
</form>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,36 @@
@model TwoFactorRecoveryViewModel
@{
ViewData["Title"] = "Use a recovery code";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Use a recovery code</h1>
<p class="lead-text">Use one of your emergency recovery codes if you cannot access your authenticator app.</p>
</div>
</div>
<section class="edit-panel">
<form asp-action="TwoFactorRecovery" method="post" class="asset-create-form">
<input asp-for="ReturnUrl" type="hidden" />
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="RecoveryCode">Recovery code</label>
<input class="form-control" asp-for="RecoveryCode" autocomplete="off" />
<span class="text-danger" asp-validation-for="RecoveryCode"></span>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Use recovery code</button>
<a class="btn btn-outline-secondary" asp-action="TwoFactor" asp-route-returnUrl="@Model.ReturnUrl">Back to authenticator code</a>
</div>
</form>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,33 @@
@model DisableTwoFactorViewModel
@{
ViewData["Title"] = "Disable Two Factor";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Disable Two Factor Authentication</h1>
<p class="lead-text">Confirm your current password to disable two-factor authentication.</p>
</div>
</div>
<section class="edit-panel">
<form asp-action="DisableTwoFactor" method="post" class="asset-create-form">
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="CurrentPassword">Current password</label>
<input class="form-control" asp-for="CurrentPassword" autocomplete="current-password" />
<span class="text-danger" asp-validation-for="CurrentPassword"></span>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-outline-danger" type="submit">Disable Two Factor</button>
<a class="btn btn-outline-secondary" asp-action="Index">Cancel</a>
</div>
</form>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,48 @@
@model EnableTwoFactorViewModel
@{
ViewData["Title"] = "Enable Two Factor";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Enable Two Factor Authentication</h1>
<p class="lead-text">Scan the QR code with Microsoft Authenticator, Google Authenticator, Authy or another compatible app.</p>
</div>
</div>
<section class="edit-panel">
@if (!string.IsNullOrWhiteSpace(Model.QrCodeImageDataUri))
{
<img src="@Model.QrCodeImageDataUri" alt="Two-factor QR code" class="img-fluid mb-3" style="max-width:220px;" />
}
else
{
<div class="alert alert-warning">QR code generation was not available. Enter the manual secret below in your authenticator app.</div>
}
<p><strong>Manual secret:</strong> <code>@Model.SecretKey</code></p>
<form asp-action="EnableTwoFactor" method="post" class="asset-create-form">
<input asp-for="SecretKey" type="hidden" />
<input asp-for="QrCodeUri" type="hidden" />
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Code">Verification code</label>
<input class="form-control" asp-for="Code" autocomplete="one-time-code" />
<span class="text-danger" asp-validation-for="Code"></span>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Enable Two Factor</button>
<a class="btn btn-outline-secondary" asp-action="Index">Cancel</a>
</div>
</form>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,34 @@
@model AppUser
@{
ViewData["Title"] = "Manage Account";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Manage Account</h1>
<p class="lead-text">@Model.Email</p>
</div>
</div>
@if (TempData["AuthMessage"] is string authMessage)
{
<div class="alert alert-info">@authMessage</div>
}
<section class="edit-panel">
<h2>Two Factor Authentication</h2>
<p class="muted">Use an authenticator app to add an extra sign-in step for your account.</p>
<p><strong>Status:</strong> @(Model.TwoFactorEnabled ? "Enabled" : "Not enabled")</p>
<div class="button-row">
@if (Model.TwoFactorEnabled)
{
<a class="btn btn-outline-primary" asp-action="RecoveryCodes">Recovery Codes</a>
<a class="btn btn-outline-danger" asp-action="DisableTwoFactor">Disable Two Factor</a>
}
else
{
<a class="btn btn-primary" asp-action="EnableTwoFactor">Enable Two Factor</a>
}
</div>
</section>

View File

@ -0,0 +1,42 @@
@model RecoveryCodesViewModel
@{
ViewData["Title"] = "Recovery Codes";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Recovery Codes</h1>
<p class="lead-text">Recovery codes can be used once if you cannot access your authenticator app.</p>
</div>
</div>
@if (TempData["AuthMessage"] is string authMessage)
{
<div class="alert alert-info">@authMessage</div>
}
<section class="edit-panel">
<div class="alert alert-warning">
Store these codes somewhere safe. They are shown only once, and each code can be used one time.
</div>
@if (Model.HasNewCodes)
{
<div class="content-grid">
@foreach (var code in Model.RecoveryCodes)
{
<div class="plain-card"><code>@code</code></div>
}
</div>
}
else
{
<p class="muted">No new recovery codes are available to display. Generate new codes if you need a fresh set.</p>
}
<form asp-action="RegenerateRecoveryCodes" method="post" class="button-row mt-3">
<button class="btn btn-outline-primary" type="submit">Regenerate Recovery Codes</button>
<a class="btn btn-outline-secondary" asp-action="Index">Back to Manage Account</a>
</form>
</section>

View File

@ -57,6 +57,7 @@
@if (User.Identity?.IsAuthenticated == true)
{
<span class="navbar-text text-dark">Hello @User.Identity.Name</span>
<a class="btn btn-outline-secondary btn-sm" asp-controller="ManageAccount" asp-action="Index">Manage Account</a>
<form asp-controller="Account" asp-action="Logout" method="post" class="m-0">
<button class="btn btn-outline-secondary btn-sm" type="submit">Logout</button>
</form>