Phase 4E AuthService 2FA
This commit is contained in:
parent
2f4590b681
commit
6895009223
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
123
PlotLine/Controllers/ManageAccountController.cs
Normal file
123
PlotLine/Controllers/ManageAccountController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
181
PlotLine/Services/TwoFactorService.cs
Normal file
181
PlotLine/Services/TwoFactorService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
155
PlotLine/Sql/040_Phase4E_TwoFactorAuthentication.sql
Normal file
155
PlotLine/Sql/040_Phase4E_TwoFactorAuthentication.sql
Normal 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
|
||||
@ -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
|
||||
|
||||
39
PlotLine/Views/Account/TwoFactor.cshtml
Normal file
39
PlotLine/Views/Account/TwoFactor.cshtml
Normal 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" />
|
||||
}
|
||||
36
PlotLine/Views/Account/TwoFactorRecovery.cshtml
Normal file
36
PlotLine/Views/Account/TwoFactorRecovery.cshtml
Normal 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" />
|
||||
}
|
||||
33
PlotLine/Views/ManageAccount/DisableTwoFactor.cshtml
Normal file
33
PlotLine/Views/ManageAccount/DisableTwoFactor.cshtml
Normal 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" />
|
||||
}
|
||||
48
PlotLine/Views/ManageAccount/EnableTwoFactor.cshtml
Normal file
48
PlotLine/Views/ManageAccount/EnableTwoFactor.cshtml
Normal 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" />
|
||||
}
|
||||
34
PlotLine/Views/ManageAccount/Index.cshtml
Normal file
34
PlotLine/Views/ManageAccount/Index.cshtml
Normal 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>
|
||||
42
PlotLine/Views/ManageAccount/RecoveryCodes.cshtml
Normal file
42
PlotLine/Views/ManageAccount/RecoveryCodes.cshtml
Normal 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>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user