Phase 4F AuthService Manage Account
This commit is contained in:
parent
6895009223
commit
b3a3c045dc
@ -26,6 +26,32 @@ public sealed class ManageAccountController(
|
|||||||
return View(user);
|
return View(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult ChangePassword()
|
||||||
|
{
|
||||||
|
return View(new ChangePasswordViewModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ChangePassword(ChangePasswordViewModel model)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await authService.ChangePasswordAsync(model);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Your password could not be changed.");
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData["AuthMessage"] = "Your password has been changed. Please sign in again.";
|
||||||
|
return RedirectToAction("Login", "Account");
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> EnableTwoFactor()
|
public async Task<IActionResult> EnableTwoFactor()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -13,6 +13,7 @@ public interface IUserRepository
|
|||||||
Task<AppUser?> UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc);
|
Task<AppUser?> UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc);
|
||||||
Task<bool> ConfirmEmailAsync(int userId, Guid token);
|
Task<bool> ConfirmEmailAsync(int userId, Guid token);
|
||||||
Task<bool> ResetPasswordAsync(int userId, Guid token, string passwordHash);
|
Task<bool> ResetPasswordAsync(int userId, Guid token, string passwordHash);
|
||||||
|
Task<AppUser?> ChangePasswordAsync(int userId, string passwordHash);
|
||||||
Task<AppUser?> EnableTwoFactorAsync(int userId);
|
Task<AppUser?> EnableTwoFactorAsync(int userId);
|
||||||
Task<AppUser?> DisableTwoFactorAsync(int userId);
|
Task<AppUser?> DisableTwoFactorAsync(int userId);
|
||||||
}
|
}
|
||||||
@ -98,6 +99,15 @@ public sealed class UserRepository(ISqlConnectionFactory connectionFactory) : IU
|
|||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AppUser?> ChangePasswordAsync(int userId, string passwordHash)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<AppUser>(
|
||||||
|
"dbo.User_ChangePassword",
|
||||||
|
new { UserID = userId, PasswordHash = passwordHash },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<AppUser?> EnableTwoFactorAsync(int userId)
|
public async Task<AppUser?> EnableTwoFactorAsync(int userId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
@ -14,6 +14,7 @@ public sealed class AppUser
|
|||||||
public DateTime CreatedUtc { get; set; }
|
public DateTime CreatedUtc { get; set; }
|
||||||
public DateTime UpdatedUtc { get; set; }
|
public DateTime UpdatedUtc { get; set; }
|
||||||
public DateTime? LastLoginUtc { get; set; }
|
public DateTime? LastLoginUtc { get; set; }
|
||||||
|
public Guid SecurityStamp { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UserEmailVerificationToken
|
public sealed class UserEmailVerificationToken
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
@ -41,6 +43,30 @@ public class Program
|
|||||||
options.Cookie.HttpOnly = true;
|
options.Cookie.HttpOnly = true;
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
options.Events = new CookieAuthenticationEvents
|
||||||
|
{
|
||||||
|
OnValidatePrincipal = async context =>
|
||||||
|
{
|
||||||
|
var userIdValue = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
var securityStampValue = context.Principal?.FindFirstValue("PlotWeaver.SecurityStamp");
|
||||||
|
if (!int.TryParse(userIdValue, out var userId) || !Guid.TryParse(securityStampValue, out var securityStamp))
|
||||||
|
{
|
||||||
|
context.RejectPrincipal();
|
||||||
|
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var scope = context.HttpContext.RequestServices.CreateScope();
|
||||||
|
var users = scope.ServiceProvider.GetRequiredService<IUserRepository>();
|
||||||
|
var user = await users.GetByIdAsync(userId);
|
||||||
|
var isLocked = user?.IsLocked == true && (!user.LockoutEndUtc.HasValue || user.LockoutEndUtc > DateTime.UtcNow);
|
||||||
|
if (user is null || user.SecurityStamp != securityStamp || isLocked)
|
||||||
|
{
|
||||||
|
context.RejectPrincipal();
|
||||||
|
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
|
builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
|
||||||
|
|||||||
@ -235,6 +235,9 @@ public sealed class AuthService(
|
|||||||
{
|
{
|
||||||
private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24);
|
private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24);
|
||||||
private static readonly TimeSpan PendingTwoFactorLifetime = TimeSpan.FromMinutes(10);
|
private static readonly TimeSpan PendingTwoFactorLifetime = TimeSpan.FromMinutes(10);
|
||||||
|
private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
|
||||||
|
private const int MaxFailedLoginAttempts = 5;
|
||||||
|
private const string SecurityStampClaimType = "PlotWeaver.SecurityStamp";
|
||||||
private const string PendingTwoFactorUserIdKey = "PlotWeaver.PendingTwoFactor.UserID";
|
private const string PendingTwoFactorUserIdKey = "PlotWeaver.PendingTwoFactor.UserID";
|
||||||
private const string PendingTwoFactorRememberMeKey = "PlotWeaver.PendingTwoFactor.RememberMe";
|
private const string PendingTwoFactorRememberMeKey = "PlotWeaver.PendingTwoFactor.RememberMe";
|
||||||
private const string PendingTwoFactorReturnUrlKey = "PlotWeaver.PendingTwoFactor.ReturnUrl";
|
private const string PendingTwoFactorReturnUrlKey = "PlotWeaver.PendingTwoFactor.ReturnUrl";
|
||||||
@ -256,6 +259,7 @@ public sealed class AuthService(
|
|||||||
var token = Guid.NewGuid();
|
var token = Guid.NewGuid();
|
||||||
await authentication.CreateEmailVerificationTokenAsync(created.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
await authentication.CreateEmailVerificationTokenAsync(created.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
||||||
await emails.SendVerificationEmailAsync(created.Email, created.DisplayName, token);
|
await emails.SendVerificationEmailAsync(created.Email, created.DisplayName, token);
|
||||||
|
await InsertLoginAuditAsync(created.UserID, created.Email, true, "Email verification requested");
|
||||||
logger.LogInformation("User {UserID} registered and was sent an email verification token.", created.UserID);
|
logger.LogInformation("User {UserID} registered and was sent an email verification token.", created.UserID);
|
||||||
|
|
||||||
return RegistrationResult.Succeeded(created.UserID);
|
return RegistrationResult.Succeeded(created.UserID);
|
||||||
@ -267,21 +271,32 @@ public sealed class AuthService(
|
|||||||
var user = await users.GetByEmailAsync(email);
|
var user = await users.GetByEmailAsync(email);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
await InsertLoginAuditAsync(null, email, false, "User not found");
|
await InsertLoginAuditAsync(null, email, false, "Login failed, unknown email");
|
||||||
return AuthenticationResult.Failed("The email address or password was not recognised.");
|
return AuthenticationResult.Failed("Invalid email address or password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsLockedOut(user))
|
||||||
|
{
|
||||||
|
await InsertLoginAuditAsync(user.UserID, email, false, "Login blocked, account locked");
|
||||||
|
return AuthenticationResult.Failed("This account is temporarily locked. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.EmailConfirmed)
|
if (!user.EmailConfirmed)
|
||||||
{
|
{
|
||||||
await InsertLoginAuditAsync(user.UserID, email, false, "Email not verified");
|
await InsertLoginAuditAsync(user.UserID, email, false, "Login blocked, email not verified");
|
||||||
return AuthenticationResult.Failed("Please verify your email address before signing in.");
|
return AuthenticationResult.Failed("Please verify your email address before signing in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passwords.VerifyPassword(model.Password, user.PasswordHash))
|
if (!passwords.VerifyPassword(model.Password, user.PasswordHash))
|
||||||
{
|
{
|
||||||
await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc: null);
|
var lockoutEndUtc = user.FailedLoginAttempts + 1 >= MaxFailedLoginAttempts
|
||||||
await InsertLoginAuditAsync(user.UserID, email, false, "Invalid password");
|
? DateTime.UtcNow.Add(LockoutDuration)
|
||||||
return AuthenticationResult.Failed("The email address or password was not recognised.");
|
: (DateTime?)null;
|
||||||
|
await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc);
|
||||||
|
await InsertLoginAuditAsync(user.UserID, email, false, lockoutEndUtc.HasValue ? "Login failed, account locked" : "Login failed, bad password");
|
||||||
|
return lockoutEndUtc.HasValue
|
||||||
|
? AuthenticationResult.Failed("This account is temporarily locked. Please try again later.")
|
||||||
|
: AuthenticationResult.Failed("Invalid email address or password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.TwoFactorEnabled)
|
if (user.TwoFactorEnabled)
|
||||||
@ -307,6 +322,13 @@ public sealed class AuthService(
|
|||||||
var httpContext = httpContextAccessor.HttpContext;
|
var httpContext = httpContextAccessor.HttpContext;
|
||||||
if (httpContext is not null)
|
if (httpContext is not null)
|
||||||
{
|
{
|
||||||
|
var userId = CurrentUserId();
|
||||||
|
if (userId.HasValue)
|
||||||
|
{
|
||||||
|
var email = httpContext.User.FindFirstValue(ClaimTypes.Email);
|
||||||
|
await InsertLoginAuditAsync(userId.Value, email ?? string.Empty, true, "Logout");
|
||||||
|
}
|
||||||
|
|
||||||
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -322,11 +344,11 @@ public sealed class AuthService(
|
|||||||
if (!twoFactor.ValidateCode(context.Value.Settings.SecretKey, model.Code, context.Value.Settings.LastUsedTimeStep, out var matchedTimeStep)
|
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 authentication.UpdateLastUsedTimeStepAsync(context.Value.User.UserID, matchedTimeStep))
|
||||||
{
|
{
|
||||||
await InsertLoginAuditAsync(context.Value.User.UserID, context.Value.User.Email, false, "Invalid two-factor code");
|
await InsertLoginAuditAsync(context.Value.User.UserID, context.Value.User.Email, false, "2FA challenge failed");
|
||||||
return AuthenticationResult.Failed("The authentication code was not recognised.");
|
return AuthenticationResult.Failed("The authentication code was not recognised.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "Two-factor login successful");
|
return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "2FA challenge succeeded");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthenticationResult> CompleteTwoFactorRecoveryLoginAsync(TwoFactorRecoveryViewModel model)
|
public async Task<AuthenticationResult> CompleteTwoFactorRecoveryLoginAsync(TwoFactorRecoveryViewModel model)
|
||||||
@ -339,11 +361,11 @@ public sealed class AuthService(
|
|||||||
|
|
||||||
if (!await TryUseRecoveryCodeAsync(context.Value.User.UserID, model.RecoveryCode))
|
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");
|
await InsertLoginAuditAsync(context.Value.User.UserID, context.Value.User.Email, false, "2FA challenge failed");
|
||||||
return AuthenticationResult.Failed("The recovery code was not recognised.");
|
return AuthenticationResult.Failed("The recovery code was not recognised.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "Two-factor recovery login successful");
|
return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "Recovery code used");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthenticationResult> VerifyEmailAsync(Guid token)
|
public async Task<AuthenticationResult> VerifyEmailAsync(Guid token)
|
||||||
@ -366,6 +388,12 @@ public sealed class AuthService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var confirmed = await users.ConfirmEmailAsync(storedToken.UserID, token);
|
var confirmed = await users.ConfirmEmailAsync(storedToken.UserID, token);
|
||||||
|
if (confirmed)
|
||||||
|
{
|
||||||
|
var verifiedUser = await users.GetByIdAsync(storedToken.UserID);
|
||||||
|
await InsertLoginAuditAsync(storedToken.UserID, verifiedUser?.Email ?? string.Empty, true, "Email verified");
|
||||||
|
}
|
||||||
|
|
||||||
return confirmed
|
return confirmed
|
||||||
? AuthenticationResult.Succeeded()
|
? AuthenticationResult.Succeeded()
|
||||||
: AuthenticationResult.Failed("This verification link could not be used.");
|
: AuthenticationResult.Failed("This verification link could not be used.");
|
||||||
@ -392,6 +420,7 @@ public sealed class AuthService(
|
|||||||
var token = Guid.NewGuid();
|
var token = Guid.NewGuid();
|
||||||
await authentication.CreateEmailVerificationTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
await authentication.CreateEmailVerificationTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
||||||
await emails.SendVerificationEmailAsync(user.Email, user.DisplayName, token);
|
await emails.SendVerificationEmailAsync(user.Email, user.DisplayName, token);
|
||||||
|
await InsertLoginAuditAsync(user.UserID, user.Email, true, "Email verification requested");
|
||||||
logger.LogInformation("Verification email resent to user {UserID}.", user.UserID);
|
logger.LogInformation("Verification email resent to user {UserID}.", user.UserID);
|
||||||
return AuthenticationResult.Succeeded();
|
return AuthenticationResult.Succeeded();
|
||||||
}
|
}
|
||||||
@ -415,6 +444,7 @@ public sealed class AuthService(
|
|||||||
var token = Guid.NewGuid();
|
var token = Guid.NewGuid();
|
||||||
await authentication.CreatePasswordResetTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
await authentication.CreatePasswordResetTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
||||||
await emails.SendPasswordResetEmailAsync(user.Email, user.DisplayName, token);
|
await emails.SendPasswordResetEmailAsync(user.Email, user.DisplayName, token);
|
||||||
|
await InsertLoginAuditAsync(user.UserID, user.Email, true, "Password reset requested");
|
||||||
return AuthenticationResult.Succeeded();
|
return AuthenticationResult.Succeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -439,13 +469,43 @@ public sealed class AuthService(
|
|||||||
|
|
||||||
var passwordHash = passwords.HashPassword(model.Password);
|
var passwordHash = passwords.HashPassword(model.Password);
|
||||||
var reset = await users.ResetPasswordAsync(storedToken.UserID, model.Token, passwordHash);
|
var reset = await users.ResetPasswordAsync(storedToken.UserID, model.Token, passwordHash);
|
||||||
|
if (reset)
|
||||||
|
{
|
||||||
|
var resetUser = await users.GetByIdAsync(storedToken.UserID);
|
||||||
|
await InsertLoginAuditAsync(storedToken.UserID, resetUser?.Email ?? string.Empty, true, "Password reset completed");
|
||||||
|
}
|
||||||
|
|
||||||
return reset
|
return reset
|
||||||
? AuthenticationResult.Succeeded()
|
? AuthenticationResult.Succeeded()
|
||||||
: AuthenticationResult.Failed("Your password could not be reset with this link.");
|
: AuthenticationResult.Failed("Your password could not be reset with this link.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model)
|
public async Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model)
|
||||||
=> throw new NotImplementedException("Password changes will be implemented in a later phase.");
|
{
|
||||||
|
var userId = CurrentUserId();
|
||||||
|
if (!userId.HasValue)
|
||||||
|
{
|
||||||
|
return AuthenticationResult.Failed("You need to sign in again before changing your password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await users.GetByIdAsync(userId.Value);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return AuthenticationResult.Failed("Your account could not be found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwords.VerifyPassword(model.CurrentPassword, user.PasswordHash))
|
||||||
|
{
|
||||||
|
await InsertLoginAuditAsync(user.UserID, user.Email, false, "Password change failed, bad password");
|
||||||
|
return AuthenticationResult.Failed("The current password was not recognised.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHash = passwords.HashPassword(model.NewPassword);
|
||||||
|
await users.ChangePasswordAsync(user.UserID, passwordHash);
|
||||||
|
await InsertLoginAuditAsync(user.UserID, user.Email, true, "Password changed");
|
||||||
|
await LogoutAsync();
|
||||||
|
return AuthenticationResult.Succeeded();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<TwoFactorSetupResult> EnableTwoFactorAsync(int userId, string secretKey, string code)
|
public async Task<TwoFactorSetupResult> EnableTwoFactorAsync(int userId, string secretKey, string code)
|
||||||
{
|
{
|
||||||
@ -464,6 +524,7 @@ public sealed class AuthService(
|
|||||||
await authentication.UpdateLastUsedTimeStepAsync(userId, matchedTimeStep);
|
await authentication.UpdateLastUsedTimeStepAsync(userId, matchedTimeStep);
|
||||||
await authentication.EnableTwoFactorAsync(userId);
|
await authentication.EnableTwoFactorAsync(userId);
|
||||||
var recoveryCodes = await GenerateRecoveryCodesAsync(userId);
|
var recoveryCodes = await GenerateRecoveryCodesAsync(userId);
|
||||||
|
await InsertLoginAuditAsync(userId, user.Email, true, "2FA enabled");
|
||||||
|
|
||||||
return TwoFactorSetupResult.Succeeded(recoveryCodes);
|
return TwoFactorSetupResult.Succeeded(recoveryCodes);
|
||||||
}
|
}
|
||||||
@ -482,6 +543,7 @@ public sealed class AuthService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await authentication.DisableTwoFactorAsync(userId);
|
await authentication.DisableTwoFactorAsync(userId);
|
||||||
|
await InsertLoginAuditAsync(user.UserID, user.Email, true, "2FA disabled");
|
||||||
return AuthenticationResult.Succeeded();
|
return AuthenticationResult.Succeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -493,6 +555,8 @@ public sealed class AuthService(
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
await authentication.SaveRecoveryCodesAsync(userId, JsonSerializer.Serialize(hashes));
|
await authentication.SaveRecoveryCodesAsync(userId, JsonSerializer.Serialize(hashes));
|
||||||
|
var user = await users.GetByIdAsync(userId);
|
||||||
|
await InsertLoginAuditAsync(userId, user?.Email ?? string.Empty, true, "Recovery codes regenerated");
|
||||||
return recoveryCodes;
|
return recoveryCodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -502,7 +566,8 @@ public sealed class AuthService(
|
|||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, user.UserID.ToString()),
|
new(ClaimTypes.NameIdentifier, user.UserID.ToString()),
|
||||||
new(ClaimTypes.Name, user.DisplayName),
|
new(ClaimTypes.Name, user.DisplayName),
|
||||||
new(ClaimTypes.Email, user.Email)
|
new(ClaimTypes.Email, user.Email),
|
||||||
|
new(SecurityStampClaimType, user.SecurityStamp.ToString("D"))
|
||||||
};
|
};
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
@ -533,6 +598,17 @@ public sealed class AuthService(
|
|||||||
|
|
||||||
private static string Clean(string value) => value.Trim();
|
private static string Clean(string value) => value.Trim();
|
||||||
|
|
||||||
|
private int? CurrentUserId()
|
||||||
|
{
|
||||||
|
var value = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
return int.TryParse(value, out var userId) ? userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLockedOut(AppUser user)
|
||||||
|
{
|
||||||
|
return user.IsLocked && (!user.LockoutEndUtc.HasValue || user.LockoutEndUtc > DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
private void SetPendingTwoFactor(int userId, bool rememberMe, string? returnUrl)
|
private void SetPendingTwoFactor(int userId, bool rememberMe, string? returnUrl)
|
||||||
{
|
{
|
||||||
var session = GetSession();
|
var session = GetSession();
|
||||||
|
|||||||
216
PlotLine/Sql/041_Phase4F_SecurityHardening.sql
Normal file
216
PlotLine/Sql/041_Phase4F_SecurityHardening.sql
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
SET ANSI_NULLS ON;
|
||||||
|
GO
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.AppUser', N'SecurityStamp') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.AppUser ADD SecurityStamp uniqueidentifier NOT NULL CONSTRAINT DF_AppUser_SecurityStamp DEFAULT NEWID();
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS
|
||||||
|
(
|
||||||
|
SELECT 1
|
||||||
|
FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID(N'dbo.UserLoginAudit')
|
||||||
|
AND name = N'Reason'
|
||||||
|
AND max_length < 1000
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.UserLoginAudit ALTER COLUMN Reason nvarchar(500) NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.User_GetByEmail
|
||||||
|
@Email nvarchar(256)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
SELECT UserID, Email, DisplayName, PasswordHash, EmailConfirmed, IsLocked, FailedLoginAttempts,
|
||||||
|
LockoutEndUtc, TwoFactorEnabled, CreatedUtc, UpdatedUtc, LastLoginUtc, SecurityStamp
|
||||||
|
FROM dbo.AppUser
|
||||||
|
WHERE Email = @Email;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.User_GetById
|
||||||
|
@UserID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
SELECT UserID, Email, DisplayName, PasswordHash, EmailConfirmed, IsLocked, FailedLoginAttempts,
|
||||||
|
LockoutEndUtc, TwoFactorEnabled, CreatedUtc, UpdatedUtc, LastLoginUtc, SecurityStamp
|
||||||
|
FROM dbo.AppUser
|
||||||
|
WHERE UserID = @UserID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.User_UpdateLoginSuccess
|
||||||
|
@UserID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
UPDATE dbo.AppUser
|
||||||
|
SET FailedLoginAttempts = 0,
|
||||||
|
IsLocked = 0,
|
||||||
|
LockoutEndUtc = NULL,
|
||||||
|
LastLoginUtc = SYSUTCDATETIME(),
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE UserID = @UserID;
|
||||||
|
|
||||||
|
EXEC dbo.User_GetById @UserID = @UserID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.User_UpdateFailedLogin
|
||||||
|
@UserID int,
|
||||||
|
@LockoutEndUtc datetime2 = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
UPDATE dbo.AppUser
|
||||||
|
SET FailedLoginAttempts = FailedLoginAttempts + 1,
|
||||||
|
IsLocked = CASE WHEN @LockoutEndUtc IS NULL THEN IsLocked ELSE 1 END,
|
||||||
|
LockoutEndUtc = COALESCE(@LockoutEndUtc, LockoutEndUtc),
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE UserID = @UserID;
|
||||||
|
|
||||||
|
EXEC dbo.User_GetById @UserID = @UserID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.User_ChangePassword
|
||||||
|
@UserID int,
|
||||||
|
@PasswordHash nvarchar(max)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
UPDATE dbo.AppUser
|
||||||
|
SET PasswordHash = @PasswordHash,
|
||||||
|
SecurityStamp = NEWID(),
|
||||||
|
FailedLoginAttempts = 0,
|
||||||
|
IsLocked = 0,
|
||||||
|
LockoutEndUtc = NULL,
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE UserID = @UserID;
|
||||||
|
|
||||||
|
EXEC dbo.User_GetById @UserID = @UserID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.User_ResetPassword
|
||||||
|
@UserID int,
|
||||||
|
@Token uniqueidentifier,
|
||||||
|
@PasswordHash nvarchar(max)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
UPDATE dbo.UserPasswordResetToken
|
||||||
|
SET UsedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE UserID = @UserID
|
||||||
|
AND Token = @Token
|
||||||
|
AND UsedUtc IS NULL
|
||||||
|
AND ExpiryUtc >= SYSUTCDATETIME();
|
||||||
|
|
||||||
|
IF @@ROWCOUNT = 1
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.AppUser
|
||||||
|
SET PasswordHash = @PasswordHash,
|
||||||
|
SecurityStamp = NEWID(),
|
||||||
|
FailedLoginAttempts = 0,
|
||||||
|
IsLocked = 0,
|
||||||
|
LockoutEndUtc = NULL,
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE UserID = @UserID;
|
||||||
|
|
||||||
|
SELECT CAST(1 AS bit);
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
|
||||||
|
SELECT CAST(0 AS bit);
|
||||||
|
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,
|
||||||
|
SecurityStamp = NEWID(),
|
||||||
|
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,
|
||||||
|
SecurityStamp = NEWID(),
|
||||||
|
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;
|
||||||
|
|
||||||
|
UPDATE dbo.AppUser
|
||||||
|
SET SecurityStamp = NEWID(),
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE UserID = @UserID;
|
||||||
|
|
||||||
|
EXEC dbo.UserTwoFactor_Get @UserID = @UserID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.User_InsertLoginAudit
|
||||||
|
@UserID int = NULL,
|
||||||
|
@EmailAttempted nvarchar(256) = NULL,
|
||||||
|
@IpAddress nvarchar(100) = NULL,
|
||||||
|
@UserAgent nvarchar(500) = NULL,
|
||||||
|
@WasSuccessful bit,
|
||||||
|
@Reason nvarchar(500) = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
INSERT INTO dbo.UserLoginAudit (UserID, EmailAttempted, IpAddress, UserAgent, WasSuccessful, Reason)
|
||||||
|
VALUES (@UserID, @EmailAttempted, @IpAddress, @UserAgent, @WasSuccessful, @Reason);
|
||||||
|
|
||||||
|
SELECT CAST(SCOPE_IDENTITY() AS bigint);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
@ -138,5 +138,5 @@ public sealed class ChangePasswordViewModel
|
|||||||
[Required(ErrorMessage = "Confirm your new password.")]
|
[Required(ErrorMessage = "Confirm your new password.")]
|
||||||
[DataType(DataType.Password)]
|
[DataType(DataType.Password)]
|
||||||
[Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match.")]
|
[Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match.")]
|
||||||
public string ConfirmPassword { get; set; } = string.Empty;
|
public string ConfirmNewPassword { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
45
PlotLine/Views/ManageAccount/ChangePassword.cshtml
Normal file
45
PlotLine/Views/ManageAccount/ChangePassword.cshtml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
@model ChangePasswordViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Change Password";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="page-heading compact">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Account</p>
|
||||||
|
<h1>Change Password</h1>
|
||||||
|
<p class="lead-text">Choose a new password for your PlotWeaver Studio account.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="edit-panel">
|
||||||
|
<form asp-action="ChangePassword" 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 class="col-md-6">
|
||||||
|
<label class="form-label" asp-for="NewPassword">New password</label>
|
||||||
|
<input class="form-control" asp-for="NewPassword" autocomplete="new-password" />
|
||||||
|
<span class="text-danger" asp-validation-for="NewPassword"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" asp-for="ConfirmNewPassword">Confirm new password</label>
|
||||||
|
<input class="form-control" asp-for="ConfirmNewPassword" autocomplete="new-password" />
|
||||||
|
<span class="text-danger" asp-validation-for="ConfirmNewPassword"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row mt-3">
|
||||||
|
<button class="btn btn-primary" type="submit">Change Password</button>
|
||||||
|
<a class="btn btn-outline-secondary" asp-action="Index">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<partial name="_ValidationScriptsPartial" />
|
||||||
|
}
|
||||||
@ -17,9 +17,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<section class="edit-panel">
|
<section class="edit-panel">
|
||||||
<h2>Two Factor Authentication</h2>
|
<h2>Account security</h2>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<p class="eyebrow">Email address</p>
|
||||||
|
<p>@Model.Email</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<p class="eyebrow">Email verified</p>
|
||||||
|
<p>@(Model.EmailConfirmed ? "Yes" : "No")</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<p class="eyebrow">Two-factor authentication</p>
|
||||||
|
<p>@(Model.TwoFactorEnabled ? "Enabled" : "Not enabled")</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="button-row mb-4">
|
||||||
|
<a class="btn btn-outline-primary" asp-action="ChangePassword">Change Password</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Two-Factor Authentication</h2>
|
||||||
<p class="muted">Use an authenticator app to add an extra sign-in step for your account.</p>
|
<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">
|
<div class="button-row">
|
||||||
@if (Model.TwoFactorEnabled)
|
@if (Model.TwoFactorEnabled)
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user