diff --git a/PlotLine/Controllers/AccountController.cs b/PlotLine/Controllers/AccountController.cs index 439274c..a6feabb 100644 --- a/PlotLine/Controllers/AccountController.cs +++ b/PlotLine/Controllers/AccountController.cs @@ -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 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 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"); + } } diff --git a/PlotLine/Controllers/ManageAccountController.cs b/PlotLine/Controllers/ManageAccountController.cs new file mode 100644 index 0000000..86f7c98 --- /dev/null +++ b/PlotLine/Controllers/ManageAccountController.cs @@ -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 Index() + { + var user = await GetCurrentUserAsync(); + if (user is null) + { + return RedirectToAction("Login", "Account"); + } + + return View(user); + } + + [HttpGet] + public async Task 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 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 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>(json) ?? Array.Empty() + : Array.Empty(); + + return View(new RecoveryCodesViewModel { RecoveryCodes = codes }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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 GetCurrentUserAsync() + { + return currentUser.UserId is int userId ? await users.GetByIdAsync(userId) : null; + } +} diff --git a/PlotLine/Data/AuthRepositories.cs b/PlotLine/Data/AuthRepositories.cs index 8e73519..7925448 100644 --- a/PlotLine/Data/AuthRepositories.cs +++ b/PlotLine/Data/AuthRepositories.cs @@ -24,7 +24,12 @@ public interface IAuthenticationRepository Task CreatePasswordResetTokenAsync(int userId, Guid token, DateTime expiryUtc); Task GetPasswordResetTokenAsync(Guid token); Task GetTwoFactorAsync(int userId); - Task SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes); + Task SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes, DateTime? confirmedUtc); + Task EnableTwoFactorAsync(int userId); + Task DisableTwoFactorAsync(int userId); + Task SaveRecoveryCodesAsync(int userId, string recoveryCodes); + Task GetRecoveryCodesAsync(int userId); + Task UpdateLastUsedTimeStepAsync(int userId, long lastUsedTimeStep); Task InsertLoginAuditAsync(UserLoginAudit audit); } @@ -154,17 +159,62 @@ public sealed class AuthenticationRepository(ISqlConnectionFactory connectionFac { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( - "dbo.User_GetTwoFactor", + "dbo.UserTwoFactor_Get", new { UserID = userId }, commandType: CommandType.StoredProcedure); } - public async Task SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes) + public async Task SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes, DateTime? confirmedUtc) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( - "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 EnableTwoFactorAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.UserTwoFactor_Enable", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task DisableTwoFactorAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.UserTwoFactor_Disable", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveRecoveryCodesAsync(int userId, string recoveryCodes) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.UserTwoFactor_SaveRecoveryCodes", + new { UserID = userId, RecoveryCodes = recoveryCodes }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetRecoveryCodesAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.UserTwoFactor_GetRecoveryCodes", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateLastUsedTimeStepAsync(int userId, long lastUsedTimeStep) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserTwoFactor_UpdateLastUsedTimeStep", + new { UserID = userId, LastUsedTimeStep = lastUsedTimeStep }, commandType: CommandType.StoredProcedure); } diff --git a/PlotLine/Models/AuthModels.cs b/PlotLine/Models/AuthModels.cs index a766b08..2308ca4 100644 --- a/PlotLine/Models/AuthModels.cs +++ b/PlotLine/Models/AuthModels.cs @@ -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 diff --git a/PlotLine/Models/AuthResultModels.cs b/PlotLine/Models/AuthResultModels.cs index 187794b..88263ed 100644 --- a/PlotLine/Models/AuthResultModels.cs +++ b/PlotLine/Models/AuthResultModels.cs @@ -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 RecoveryCodes { get; init; } = Array.Empty(); + + public static TwoFactorSetupResult Succeeded(IReadOnlyList recoveryCodes) + => new() { Success = true, RecoveryCodes = recoveryCodes }; + + public new static TwoFactorSetupResult Failed(string errorMessage) => new() { ErrorMessage = errorMessage }; +} diff --git a/PlotLine/PlotLine.csproj b/PlotLine/PlotLine.csproj index da6cfad..e714d68 100644 --- a/PlotLine/PlotLine.csproj +++ b/PlotLine/PlotLine.csproj @@ -10,6 +10,7 @@ + diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index b7e6f40..e53faa1 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -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(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(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase.")); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -111,6 +119,7 @@ public class Program app.UseStaticFiles(); app.UseRouting(); + app.UseSession(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/PlotLine/Services/AuthServiceInterfaces.cs b/PlotLine/Services/AuthServiceInterfaces.cs index f7c18b7..a92ec28 100644 --- a/PlotLine/Services/AuthServiceInterfaces.cs +++ b/PlotLine/Services/AuthServiceInterfaces.cs @@ -7,15 +7,18 @@ public interface IAuthService { Task RegisterAsync(RegisterViewModel model); Task LoginAsync(LoginViewModel model); - Task CompleteTwoFactorLoginAsync(TwoFactorViewModel model); + bool HasPendingTwoFactorLogin(); + string? GetPendingTwoFactorReturnUrl(); + Task CompleteTwoFactorLoginAsync(TwoFactorCodeViewModel model); + Task CompleteTwoFactorRecoveryLoginAsync(TwoFactorRecoveryViewModel model); Task LogoutAsync(); Task VerifyEmailAsync(Guid token); Task ResendVerificationEmailAsync(string email); Task ForgotPasswordAsync(ForgotPasswordViewModel model); Task ResetPasswordAsync(ResetPasswordViewModel model); Task ChangePasswordAsync(ChangePasswordViewModel model); - Task EnableTwoFactorAsync(int userId); - Task DisableTwoFactorAsync(int userId); + Task EnableTwoFactorAsync(int userId, string secretKey, string code); + Task DisableTwoFactorAsync(int userId, string currentPassword); Task> 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 GenerateRecoveryCodes(int count); } diff --git a/PlotLine/Services/AuthServices.cs b/PlotLine/Services/AuthServices.cs index c1ec913..8edc345 100644 --- a/PlotLine/Services/AuthServices.cs +++ b/PlotLine/Services/AuthServices.cs @@ -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 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 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 CompleteTwoFactorLoginAsync(TwoFactorViewModel model) - => throw new NotImplementedException("Two-factor login will be implemented in a later phase."); + public async Task 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 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 VerifyEmailAsync(Guid token) { @@ -397,14 +447,54 @@ public sealed class AuthService( public Task ChangePasswordAsync(ChangePasswordViewModel model) => throw new NotImplementedException("Password changes will be implemented in a later phase."); - public Task EnableTwoFactorAsync(int userId) - => throw new NotImplementedException("Two-factor setup will be implemented in a later phase."); + public async Task 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 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> 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 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> 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 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 TryUseRecoveryCodeAsync(int userId, string recoveryCode) + { + var stored = await authentication.GetRecoveryCodesAsync(userId); + if (string.IsNullOrWhiteSpace(stored)) + { + return false; + } + + var hashes = JsonSerializer.Deserialize>(stored) ?? new List(); + 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(); + } } diff --git a/PlotLine/Services/TwoFactorService.cs b/PlotLine/Services/TwoFactorService.cs new file mode 100644 index 0000000..edf2bad --- /dev/null +++ b/PlotLine/Services/TwoFactorService.cs @@ -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 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 GenerateRecoveryCodes(int count) + { + var codes = new List(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 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(); + 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); + } +} diff --git a/PlotLine/Sql/040_Phase4E_TwoFactorAuthentication.sql b/PlotLine/Sql/040_Phase4E_TwoFactorAuthentication.sql new file mode 100644 index 0000000..42bc373 --- /dev/null +++ b/PlotLine/Sql/040_Phase4E_TwoFactorAuthentication.sql @@ -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 diff --git a/PlotLine/ViewModels/AuthViewModels.cs b/PlotLine/ViewModels/AuthViewModels.cs index 8650af0..a36a586 100644 --- a/PlotLine/ViewModels/AuthViewModels.cs +++ b/PlotLine/ViewModels/AuthViewModels.cs @@ -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 RecoveryCodes { get; set; } = Array.Empty(); + public bool HasNewCodes => RecoveryCodes.Any(); } public sealed class ChangePasswordViewModel diff --git a/PlotLine/Views/Account/TwoFactor.cshtml b/PlotLine/Views/Account/TwoFactor.cshtml new file mode 100644 index 0000000..2469424 --- /dev/null +++ b/PlotLine/Views/Account/TwoFactor.cshtml @@ -0,0 +1,39 @@ +@model TwoFactorCodeViewModel +@{ + ViewData["Title"] = "Two-factor authentication"; +} + +
+
+

Account

+

Two-factor authentication

+

Enter the current 6-digit code from your authenticator app.

+
+
+ +
+
+ +
+ +
+
+ + + +
+
+ +
+ + Back to Login +
+

+ Can't access your authenticator app? Use a recovery code. +

+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/Account/TwoFactorRecovery.cshtml b/PlotLine/Views/Account/TwoFactorRecovery.cshtml new file mode 100644 index 0000000..5e5ca84 --- /dev/null +++ b/PlotLine/Views/Account/TwoFactorRecovery.cshtml @@ -0,0 +1,36 @@ +@model TwoFactorRecoveryViewModel +@{ + ViewData["Title"] = "Use a recovery code"; +} + +
+
+

Account

+

Use a recovery code

+

Use one of your emergency recovery codes if you cannot access your authenticator app.

+
+
+ +
+
+ +
+ +
+
+ + + +
+
+ +
+ + Back to authenticator code +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/ManageAccount/DisableTwoFactor.cshtml b/PlotLine/Views/ManageAccount/DisableTwoFactor.cshtml new file mode 100644 index 0000000..c4d8cb7 --- /dev/null +++ b/PlotLine/Views/ManageAccount/DisableTwoFactor.cshtml @@ -0,0 +1,33 @@ +@model DisableTwoFactorViewModel +@{ + ViewData["Title"] = "Disable Two Factor"; +} + +
+
+

Account

+

Disable Two Factor Authentication

+

Confirm your current password to disable two-factor authentication.

+
+
+ +
+
+
+
+
+ + + +
+
+
+ + Cancel +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/ManageAccount/EnableTwoFactor.cshtml b/PlotLine/Views/ManageAccount/EnableTwoFactor.cshtml new file mode 100644 index 0000000..b5bc15b --- /dev/null +++ b/PlotLine/Views/ManageAccount/EnableTwoFactor.cshtml @@ -0,0 +1,48 @@ +@model EnableTwoFactorViewModel +@{ + ViewData["Title"] = "Enable Two Factor"; +} + +
+
+

Account

+

Enable Two Factor Authentication

+

Scan the QR code with Microsoft Authenticator, Google Authenticator, Authy or another compatible app.

+
+
+ +
+ @if (!string.IsNullOrWhiteSpace(Model.QrCodeImageDataUri)) + { + Two-factor QR code + } + else + { +
QR code generation was not available. Enter the manual secret below in your authenticator app.
+ } + +

Manual secret: @Model.SecretKey

+ +
+ + +
+ +
+
+ + + +
+
+ +
+ + Cancel +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/ManageAccount/Index.cshtml b/PlotLine/Views/ManageAccount/Index.cshtml new file mode 100644 index 0000000..8a3107a --- /dev/null +++ b/PlotLine/Views/ManageAccount/Index.cshtml @@ -0,0 +1,34 @@ +@model AppUser +@{ + ViewData["Title"] = "Manage Account"; +} + +
+
+

Account

+

Manage Account

+

@Model.Email

+
+
+ +@if (TempData["AuthMessage"] is string authMessage) +{ +
@authMessage
+} + +
+

Two Factor Authentication

+

Use an authenticator app to add an extra sign-in step for your account.

+

Status: @(Model.TwoFactorEnabled ? "Enabled" : "Not enabled")

+
+ @if (Model.TwoFactorEnabled) + { + Recovery Codes + Disable Two Factor + } + else + { + Enable Two Factor + } +
+
diff --git a/PlotLine/Views/ManageAccount/RecoveryCodes.cshtml b/PlotLine/Views/ManageAccount/RecoveryCodes.cshtml new file mode 100644 index 0000000..34b7d1d --- /dev/null +++ b/PlotLine/Views/ManageAccount/RecoveryCodes.cshtml @@ -0,0 +1,42 @@ +@model RecoveryCodesViewModel +@{ + ViewData["Title"] = "Recovery Codes"; +} + +
+
+

Account

+

Recovery Codes

+

Recovery codes can be used once if you cannot access your authenticator app.

+
+
+ +@if (TempData["AuthMessage"] is string authMessage) +{ +
@authMessage
+} + +
+
+ Store these codes somewhere safe. They are shown only once, and each code can be used one time. +
+ + @if (Model.HasNewCodes) + { +
+ @foreach (var code in Model.RecoveryCodes) + { +
@code
+ } +
+ } + else + { +

No new recovery codes are available to display. Generate new codes if you need a fresh set.

+ } + +
+ + Back to Manage Account +
+
diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index 84b7f92..241ea82 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -57,6 +57,7 @@ @if (User.Identity?.IsAuthenticated == true) { Hello @User.Identity.Name + Manage Account