Phase 4F AuthService Manage Account

This commit is contained in:
Nick Beckley 2026-06-06 20:17:16 +01:00
parent 6895009223
commit b3a3c045dc
9 changed files with 434 additions and 16 deletions

View File

@ -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()
{

View File

@ -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();

View File

@ -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

View File

@ -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>();

View File

@ -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();

View 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

View File

@ -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;
}

View 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" />
}

View File

@ -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)
{