diff --git a/PlotLine/Controllers/ManageAccountController.cs b/PlotLine/Controllers/ManageAccountController.cs index 86f7c98..c3b8aef 100644 --- a/PlotLine/Controllers/ManageAccountController.cs +++ b/PlotLine/Controllers/ManageAccountController.cs @@ -26,6 +26,32 @@ public sealed class ManageAccountController( return View(user); } + [HttpGet] + public IActionResult ChangePassword() + { + return View(new ChangePasswordViewModel()); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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 EnableTwoFactor() { diff --git a/PlotLine/Data/AuthRepositories.cs b/PlotLine/Data/AuthRepositories.cs index 7925448..986f00d 100644 --- a/PlotLine/Data/AuthRepositories.cs +++ b/PlotLine/Data/AuthRepositories.cs @@ -13,6 +13,7 @@ public interface IUserRepository Task UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc); Task ConfirmEmailAsync(int userId, Guid token); Task ResetPasswordAsync(int userId, Guid token, string passwordHash); + Task ChangePasswordAsync(int userId, string passwordHash); Task EnableTwoFactorAsync(int userId); Task DisableTwoFactorAsync(int userId); } @@ -98,6 +99,15 @@ public sealed class UserRepository(ISqlConnectionFactory connectionFactory) : IU commandType: CommandType.StoredProcedure); } + public async Task ChangePasswordAsync(int userId, string passwordHash) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.User_ChangePassword", + new { UserID = userId, PasswordHash = passwordHash }, + commandType: CommandType.StoredProcedure); + } + public async Task EnableTwoFactorAsync(int userId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/AuthModels.cs b/PlotLine/Models/AuthModels.cs index 2308ca4..9f68038 100644 --- a/PlotLine/Models/AuthModels.cs +++ b/PlotLine/Models/AuthModels.cs @@ -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 diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index e53faa1..c97584a 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -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(); + 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(); diff --git a/PlotLine/Services/AuthServices.cs b/PlotLine/Services/AuthServices.cs index 8edc345..83ce803 100644 --- a/PlotLine/Services/AuthServices.cs +++ b/PlotLine/Services/AuthServices.cs @@ -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 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 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 ChangePasswordAsync(ChangePasswordViewModel model) - => throw new NotImplementedException("Password changes will be implemented in a later phase."); + public async Task 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 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(); diff --git a/PlotLine/Sql/041_Phase4F_SecurityHardening.sql b/PlotLine/Sql/041_Phase4F_SecurityHardening.sql new file mode 100644 index 0000000..1d60ba6 --- /dev/null +++ b/PlotLine/Sql/041_Phase4F_SecurityHardening.sql @@ -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 diff --git a/PlotLine/ViewModels/AuthViewModels.cs b/PlotLine/ViewModels/AuthViewModels.cs index a36a586..563d0ac 100644 --- a/PlotLine/ViewModels/AuthViewModels.cs +++ b/PlotLine/ViewModels/AuthViewModels.cs @@ -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; } diff --git a/PlotLine/Views/ManageAccount/ChangePassword.cshtml b/PlotLine/Views/ManageAccount/ChangePassword.cshtml new file mode 100644 index 0000000..bc40608 --- /dev/null +++ b/PlotLine/Views/ManageAccount/ChangePassword.cshtml @@ -0,0 +1,45 @@ +@model ChangePasswordViewModel +@{ + ViewData["Title"] = "Change Password"; +} + +
+
+

Account

+

Change Password

+

Choose a new password for your PlotWeaver Studio account.

+
+
+ +
+
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ + Cancel +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/ManageAccount/Index.cshtml b/PlotLine/Views/ManageAccount/Index.cshtml index 8a3107a..90974ce 100644 --- a/PlotLine/Views/ManageAccount/Index.cshtml +++ b/PlotLine/Views/ManageAccount/Index.cshtml @@ -17,9 +17,27 @@ }
-

Two Factor Authentication

+

Account security

+
+
+

Email address

+

@Model.Email

+
+
+

Email verified

+

@(Model.EmailConfirmed ? "Yes" : "No")

+
+
+

Two-factor authentication

+

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

+
+
+ + +

Two-Factor Authentication

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

-

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

@if (Model.TwoFactorEnabled) {