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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task<IActionResult> EnableTwoFactor()
|
||||
{
|
||||
|
||||
@ -13,6 +13,7 @@ public interface IUserRepository
|
||||
Task<AppUser?> UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc);
|
||||
Task<bool> ConfirmEmailAsync(int userId, Guid token);
|
||||
Task<bool> ResetPasswordAsync(int userId, Guid token, string passwordHash);
|
||||
Task<AppUser?> ChangePasswordAsync(int userId, string passwordHash);
|
||||
Task<AppUser?> EnableTwoFactorAsync(int userId);
|
||||
Task<AppUser?> DisableTwoFactorAsync(int userId);
|
||||
}
|
||||
@ -98,6 +99,15 @@ public sealed class UserRepository(ISqlConnectionFactory connectionFactory) : IU
|
||||
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)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -14,6 +14,7 @@ public sealed class AppUser
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; }
|
||||
public DateTime? LastLoginUtc { get; set; }
|
||||
public Guid SecurityStamp { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserEmailVerificationToken
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.Services;
|
||||
@ -41,6 +43,30 @@ public class Program
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
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.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
|
||||
|
||||
@ -235,6 +235,9 @@ public sealed class AuthService(
|
||||
{
|
||||
private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan PendingTwoFactorLifetime = TimeSpan.FromMinutes(10);
|
||||
private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
|
||||
private const int MaxFailedLoginAttempts = 5;
|
||||
private const string SecurityStampClaimType = "PlotWeaver.SecurityStamp";
|
||||
private const string PendingTwoFactorUserIdKey = "PlotWeaver.PendingTwoFactor.UserID";
|
||||
private const string PendingTwoFactorRememberMeKey = "PlotWeaver.PendingTwoFactor.RememberMe";
|
||||
private const string PendingTwoFactorReturnUrlKey = "PlotWeaver.PendingTwoFactor.ReturnUrl";
|
||||
@ -256,6 +259,7 @@ public sealed class AuthService(
|
||||
var token = Guid.NewGuid();
|
||||
await authentication.CreateEmailVerificationTokenAsync(created.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
||||
await emails.SendVerificationEmailAsync(created.Email, created.DisplayName, token);
|
||||
await InsertLoginAuditAsync(created.UserID, created.Email, true, "Email verification requested");
|
||||
logger.LogInformation("User {UserID} registered and was sent an email verification token.", created.UserID);
|
||||
|
||||
return RegistrationResult.Succeeded(created.UserID);
|
||||
@ -267,21 +271,32 @@ public sealed class AuthService(
|
||||
var user = await users.GetByEmailAsync(email);
|
||||
if (user is null)
|
||||
{
|
||||
await InsertLoginAuditAsync(null, email, false, "User not found");
|
||||
return AuthenticationResult.Failed("The email address or password was not recognised.");
|
||||
await InsertLoginAuditAsync(null, email, false, "Login failed, unknown email");
|
||||
return AuthenticationResult.Failed("Invalid email address or password.");
|
||||
}
|
||||
|
||||
if (IsLockedOut(user))
|
||||
{
|
||||
await InsertLoginAuditAsync(user.UserID, email, false, "Login blocked, account locked");
|
||||
return AuthenticationResult.Failed("This account is temporarily locked. Please try again later.");
|
||||
}
|
||||
|
||||
if (!user.EmailConfirmed)
|
||||
{
|
||||
await InsertLoginAuditAsync(user.UserID, email, false, "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.");
|
||||
}
|
||||
|
||||
if (!passwords.VerifyPassword(model.Password, user.PasswordHash))
|
||||
{
|
||||
await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc: null);
|
||||
await InsertLoginAuditAsync(user.UserID, email, false, "Invalid password");
|
||||
return AuthenticationResult.Failed("The email address or password was not recognised.");
|
||||
var lockoutEndUtc = user.FailedLoginAttempts + 1 >= MaxFailedLoginAttempts
|
||||
? DateTime.UtcNow.Add(LockoutDuration)
|
||||
: (DateTime?)null;
|
||||
await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc);
|
||||
await InsertLoginAuditAsync(user.UserID, email, false, lockoutEndUtc.HasValue ? "Login failed, account locked" : "Login failed, bad password");
|
||||
return lockoutEndUtc.HasValue
|
||||
? AuthenticationResult.Failed("This account is temporarily locked. Please try again later.")
|
||||
: AuthenticationResult.Failed("Invalid email address or password.");
|
||||
}
|
||||
|
||||
if (user.TwoFactorEnabled)
|
||||
@ -307,6 +322,13 @@ public sealed class AuthService(
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext is not null)
|
||||
{
|
||||
var userId = CurrentUserId();
|
||||
if (userId.HasValue)
|
||||
{
|
||||
var email = httpContext.User.FindFirstValue(ClaimTypes.Email);
|
||||
await InsertLoginAuditAsync(userId.Value, email ?? string.Empty, true, "Logout");
|
||||
}
|
||||
|
||||
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
}
|
||||
}
|
||||
@ -322,11 +344,11 @@ public sealed class AuthService(
|
||||
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");
|
||||
await InsertLoginAuditAsync(context.Value.User.UserID, context.Value.User.Email, false, "2FA challenge failed");
|
||||
return AuthenticationResult.Failed("The authentication code was not recognised.");
|
||||
}
|
||||
|
||||
return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "Two-factor login successful");
|
||||
return await CompletePendingTwoFactorSignInAsync(context.Value.User, context.Value.Pending.RememberMe, "2FA challenge succeeded");
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> CompleteTwoFactorRecoveryLoginAsync(TwoFactorRecoveryViewModel model)
|
||||
@ -339,11 +361,11 @@ public sealed class AuthService(
|
||||
|
||||
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 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)
|
||||
@ -366,6 +388,12 @@ public sealed class AuthService(
|
||||
}
|
||||
|
||||
var confirmed = await users.ConfirmEmailAsync(storedToken.UserID, token);
|
||||
if (confirmed)
|
||||
{
|
||||
var verifiedUser = await users.GetByIdAsync(storedToken.UserID);
|
||||
await InsertLoginAuditAsync(storedToken.UserID, verifiedUser?.Email ?? string.Empty, true, "Email verified");
|
||||
}
|
||||
|
||||
return confirmed
|
||||
? AuthenticationResult.Succeeded()
|
||||
: AuthenticationResult.Failed("This verification link could not be used.");
|
||||
@ -392,6 +420,7 @@ public sealed class AuthService(
|
||||
var token = Guid.NewGuid();
|
||||
await authentication.CreateEmailVerificationTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
||||
await emails.SendVerificationEmailAsync(user.Email, user.DisplayName, token);
|
||||
await InsertLoginAuditAsync(user.UserID, user.Email, true, "Email verification requested");
|
||||
logger.LogInformation("Verification email resent to user {UserID}.", user.UserID);
|
||||
return AuthenticationResult.Succeeded();
|
||||
}
|
||||
@ -415,6 +444,7 @@ public sealed class AuthService(
|
||||
var token = Guid.NewGuid();
|
||||
await authentication.CreatePasswordResetTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
||||
await emails.SendPasswordResetEmailAsync(user.Email, user.DisplayName, token);
|
||||
await InsertLoginAuditAsync(user.UserID, user.Email, true, "Password reset requested");
|
||||
return AuthenticationResult.Succeeded();
|
||||
}
|
||||
|
||||
@ -439,13 +469,43 @@ public sealed class AuthService(
|
||||
|
||||
var passwordHash = passwords.HashPassword(model.Password);
|
||||
var reset = await users.ResetPasswordAsync(storedToken.UserID, model.Token, passwordHash);
|
||||
if (reset)
|
||||
{
|
||||
var resetUser = await users.GetByIdAsync(storedToken.UserID);
|
||||
await InsertLoginAuditAsync(storedToken.UserID, resetUser?.Email ?? string.Empty, true, "Password reset completed");
|
||||
}
|
||||
|
||||
return reset
|
||||
? AuthenticationResult.Succeeded()
|
||||
: AuthenticationResult.Failed("Your password could not be reset with this link.");
|
||||
}
|
||||
|
||||
public Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model)
|
||||
=> throw new NotImplementedException("Password changes will be implemented in a later phase.");
|
||||
public async Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model)
|
||||
{
|
||||
var userId = CurrentUserId();
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
return AuthenticationResult.Failed("You need to sign in again before changing your password.");
|
||||
}
|
||||
|
||||
var user = await users.GetByIdAsync(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return AuthenticationResult.Failed("Your account could not be found.");
|
||||
}
|
||||
|
||||
if (!passwords.VerifyPassword(model.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
await InsertLoginAuditAsync(user.UserID, user.Email, false, "Password change failed, bad password");
|
||||
return AuthenticationResult.Failed("The current password was not recognised.");
|
||||
}
|
||||
|
||||
var passwordHash = passwords.HashPassword(model.NewPassword);
|
||||
await users.ChangePasswordAsync(user.UserID, passwordHash);
|
||||
await InsertLoginAuditAsync(user.UserID, user.Email, true, "Password changed");
|
||||
await LogoutAsync();
|
||||
return AuthenticationResult.Succeeded();
|
||||
}
|
||||
|
||||
public async Task<TwoFactorSetupResult> EnableTwoFactorAsync(int userId, string secretKey, string code)
|
||||
{
|
||||
@ -464,6 +524,7 @@ public sealed class AuthService(
|
||||
await authentication.UpdateLastUsedTimeStepAsync(userId, matchedTimeStep);
|
||||
await authentication.EnableTwoFactorAsync(userId);
|
||||
var recoveryCodes = await GenerateRecoveryCodesAsync(userId);
|
||||
await InsertLoginAuditAsync(userId, user.Email, true, "2FA enabled");
|
||||
|
||||
return TwoFactorSetupResult.Succeeded(recoveryCodes);
|
||||
}
|
||||
@ -482,6 +543,7 @@ public sealed class AuthService(
|
||||
}
|
||||
|
||||
await authentication.DisableTwoFactorAsync(userId);
|
||||
await InsertLoginAuditAsync(user.UserID, user.Email, true, "2FA disabled");
|
||||
return AuthenticationResult.Succeeded();
|
||||
}
|
||||
|
||||
@ -493,6 +555,8 @@ public sealed class AuthService(
|
||||
.ToArray();
|
||||
|
||||
await authentication.SaveRecoveryCodesAsync(userId, JsonSerializer.Serialize(hashes));
|
||||
var user = await users.GetByIdAsync(userId);
|
||||
await InsertLoginAuditAsync(userId, user?.Email ?? string.Empty, true, "Recovery codes regenerated");
|
||||
return recoveryCodes;
|
||||
}
|
||||
|
||||
@ -502,7 +566,8 @@ public sealed class AuthService(
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.UserID.ToString()),
|
||||
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);
|
||||
@ -533,6 +598,17 @@ public sealed class AuthService(
|
||||
|
||||
private static string Clean(string value) => value.Trim();
|
||||
|
||||
private int? CurrentUserId()
|
||||
{
|
||||
var value = httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return int.TryParse(value, out var userId) ? userId : null;
|
||||
}
|
||||
|
||||
private static bool IsLockedOut(AppUser user)
|
||||
{
|
||||
return user.IsLocked && (!user.LockoutEndUtc.HasValue || user.LockoutEndUtc > DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private void SetPendingTwoFactor(int userId, bool rememberMe, string? returnUrl)
|
||||
{
|
||||
var session = GetSession();
|
||||
|
||||
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.")]
|
||||
[DataType(DataType.Password)]
|
||||
[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">
|
||||
<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><strong>Status:</strong> @(Model.TwoFactorEnabled ? "Enabled" : "Not enabled")</p>
|
||||
<div class="button-row">
|
||||
@if (Model.TwoFactorEnabled)
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user