Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
86a45ef
Add MFA persistence
StuartFerguson Oct 6, 2026
4fc5bb6
Add management API MFA policies
StuartFerguson Oct 6, 2026
9d7da19
Add TOTP enrollment and recovery codes
StuartFerguson Oct 6, 2026
f13563a
Add protected MFA login challenge
StuartFerguson Oct 6, 2026
4f2831b
Enforce MFA for OIDC password grants
StuartFerguson Oct 6, 2026
46e039a
Document MFA operations and lockout behavior
StuartFerguson Oct 6, 2026
f56eb7c
Test protected MFA sign-in transactions
StuartFerguson Oct 6, 2026
be09f9d
Cover MFA login page transitions
StuartFerguson Oct 6, 2026
df403b6
Cover MFA policy management integration
StuartFerguson Oct 6, 2026
02fc563
Cover MFA password grant rejection
StuartFerguson Oct 6, 2026
7a692ca
Cover browser MFA enrollment and login
StuartFerguson Oct 6, 2026
3583fcd
Cover one-time MFA recovery codes
StuartFerguson Oct 6, 2026
c266861
Cover role MFA policy management
StuartFerguson Oct 6, 2026
8057e80
Clear temporary MFA challenge after sign-in
StuartFerguson Oct 6, 2026
af94b87
Configure persistent MFA data protection
StuartFerguson Oct 6, 2026
ea627ba
Add MFA enrollment QR code
StuartFerguson Oct 6, 2026
6c2155c
Load Kestrel certificates with ephemeral keys
StuartFerguson Oct 6, 2026
167b15e
Configure persistent Data Protection key directory
StuartFerguson Oct 6, 2026
73b1910
Register MFA policy service interface
StuartFerguson Oct 7, 2026
7fbfe8e
Restore hybrid flow behind feature flag
StuartFerguson Oct 7, 2026
fac6295
Address MFA pull request feedback
StuartFerguson Oct 7, 2026
fbb66d5
fix codacy issue
StuartFerguson Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<PackageVersion Include="ClientProxyBase" Version="2026.9.7" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="10.0.12" />
<PackageVersion Include="QRCoder" Version="1.6.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.12" />
<PackageVersion Include="Reqnroll" Version="3.3.4" />
<PackageVersion Include="Reqnroll.Tools.MsBuild.Generation" Version="3.3.4" />
Expand All @@ -56,4 +57,4 @@
<PackageVersion Include="Selenium.WebDriver" Version="4.49.0" />
<PackageVersion Include="Reqnroll.NUnit" Version="3.3.4" />
</ItemGroup>
</Project>
</Project>
79 changes: 79 additions & 0 deletions SecurityService.BusinessLogic/Mfa/LoginMfaModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.WebUtilities;

namespace SecurityService.BusinessLogic.Mfa;

public abstract record LoginOutcome;
public sealed record LoginCompleted(string ReturnUrl = "/") : LoginOutcome;
public sealed record LoginRequiresMfa(string Transaction) : LoginOutcome;
public sealed record LoginRequiresMfaEnrollment(string Transaction) : LoginOutcome;
public sealed record LoginRejected(string Message) : LoginOutcome;

public sealed record MfaSignInTransaction(string UserId, string ReturnUrl, bool RememberLogin, DateTimeOffset ExpiresUtc);

public sealed class MfaSignInTransactionProtector
{
private readonly IDataProtector _protector;

public MfaSignInTransactionProtector(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("SecurityService.MfaSignInTransaction.v1");
}

public string Protect(string userId, string returnUrl, bool rememberLogin)
{
var transaction = new MfaSignInTransaction(userId, returnUrl, rememberLogin, DateTimeOffset.UtcNow.AddMinutes(5));
var payload = JsonSerializer.SerializeToUtf8Bytes(transaction);
return WebEncoders.Base64UrlEncode(_protector.Protect(payload));
}

public bool TryUnprotect(string value, out MfaSignInTransaction? transaction)
{
transaction = null;
try
{
var payload = _protector.Unprotect(WebEncoders.Base64UrlDecode(value));
transaction = JsonSerializer.Deserialize<MfaSignInTransaction>(payload);
return transaction is not null && transaction.ExpiresUtc > DateTimeOffset.UtcNow;
}
catch (Exception) when (value.Length > 0)
{
return false;
}
}
}

public sealed record MfaEnrollmentTransaction(string UserId, string ReturnUrl, DateTimeOffset ExpiresUtc);

public sealed class MfaEnrollmentTransactionProtector
{
private readonly IDataProtector _protector;

public MfaEnrollmentTransactionProtector(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("SecurityService.MfaEnrollmentTransaction.v1");
}

public string Protect(string userId, string returnUrl)
{
var transaction = new MfaEnrollmentTransaction(userId, returnUrl, DateTimeOffset.UtcNow.AddMinutes(10));
var payload = JsonSerializer.SerializeToUtf8Bytes(transaction);
return WebEncoders.Base64UrlEncode(_protector.Protect(payload));
}

public bool TryUnprotect(string value, out MfaEnrollmentTransaction? transaction)
{
transaction = null;
try
{
var payload = _protector.Unprotect(WebEncoders.Base64UrlDecode(value));
transaction = JsonSerializer.Deserialize<MfaEnrollmentTransaction>(payload);
return transaction is not null && transaction.ExpiresUtc > DateTimeOffset.UtcNow;
}
catch (Exception) when (value.Length > 0)
{
return false;
}
}
}
163 changes: 163 additions & 0 deletions SecurityService.BusinessLogic/Mfa/MfaAccountService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SecurityService.Database.DbContexts;
using SecurityService.Database.Entities;

namespace SecurityService.BusinessLogic.Mfa;

public sealed record MfaEnrollment(string AuthenticatorKey, string OtpauthUri);

public sealed class MfaAccountService
{
private const string AuthenticatorProvider = "Authenticator";
private readonly UserManager<ApplicationUser> _userManager;
private readonly IPasswordHasher<ApplicationUser> _passwordHasher;
private readonly SecurityServiceDbContext _dbContext;
private readonly RecoveryCodeGenerator _recoveryCodeGenerator;

public MfaAccountService(
UserManager<ApplicationUser> userManager,
IPasswordHasher<ApplicationUser> passwordHasher,
SecurityServiceDbContext dbContext,
RecoveryCodeGenerator recoveryCodeGenerator)
{
_userManager = userManager;
_passwordHasher = passwordHasher;
_dbContext = dbContext;
_recoveryCodeGenerator = recoveryCodeGenerator;
}

public async Task<MfaEnrollment> BeginEnrollmentAsync(ApplicationUser user, string issuer, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var resetResult = await _userManager.ResetAuthenticatorKeyAsync(user);
if (!resetResult.Succeeded)
{
throw new InvalidOperationException(string.Join("; ", resetResult.Errors.Select(error => error.Description)));
}

return await GetEnrollmentAsync(user, issuer, cancellationToken);
}

public async Task<MfaEnrollment> GetEnrollmentAsync(ApplicationUser user, string issuer, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var key = await _userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrWhiteSpace(key))
{
throw new InvalidOperationException("The authenticator key could not be generated.");
}

var account = user.Email ?? user.UserName ?? user.Id;
var uri = $"otpauth://totp/{Uri.EscapeDataString(issuer)}:{Uri.EscapeDataString(account)}?secret={key}&issuer={Uri.EscapeDataString(issuer)}";
return new MfaEnrollment(key, uri);
}

public async Task<bool> ConfirmEnrollmentAsync(ApplicationUser user, string code, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(code))
{
return false;
}

var valid = await _userManager.VerifyTwoFactorTokenAsync(user, AuthenticatorProvider, code);
if (valid == false)
{
return false;
}

return (await _userManager.SetTwoFactorEnabledAsync(user, true)).Succeeded;
}

public async Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(ApplicationUser user, int count, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var existingCodes = await _dbContext.RecoveryCodes.Where(code => code.UserId == user.Id).ToListAsync(cancellationToken);
_dbContext.RecoveryCodes.RemoveRange(existingCodes);

var codes = _recoveryCodeGenerator.Generate(count);
foreach (var code in codes)
{
_dbContext.RecoveryCodes.Add(new RecoveryCode
{
Id = Guid.NewGuid(),
UserId = user.Id,
CodeHash = _passwordHasher.HashPassword(user, code),
CreatedUtc = DateTime.UtcNow
});
}

await _dbContext.SaveChangesAsync(cancellationToken);
return codes;
}

public async Task<bool> RedeemRecoveryCodeAsync(ApplicationUser user, string code, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(code))
{
return false;
}

var candidates = await _dbContext.RecoveryCodes
.Where(recoveryCode => recoveryCode.UserId == user.Id && recoveryCode.ConsumedUtc == null)
.ToListAsync(cancellationToken);
foreach (var candidate in candidates)
{
var verification = _passwordHasher.VerifyHashedPassword(user, candidate.CodeHash, code);
if (verification == PasswordVerificationResult.Failed)
{
continue;
}

candidate.ConcurrencyStamp = Guid.NewGuid().ToString("N");
candidate.ConsumedUtc = DateTime.UtcNow;
try
{
await _dbContext.SaveChangesAsync(cancellationToken);
Comment thread
StuartFerguson marked this conversation as resolved.
Resolved
return true;
}
catch (DbUpdateConcurrencyException)
{
return false;
}
}

return false;
}

public async Task<bool> RemoveAuthenticatorAsync(ApplicationUser user, string currentPassword, string? code, CancellationToken cancellationToken)
{
var reauthenticated = await _userManager.CheckPasswordAsync(user, currentPassword);
if (reauthenticated == false && string.IsNullOrWhiteSpace(code) == false)
{
reauthenticated = await _userManager.VerifyTwoFactorTokenAsync(user, AuthenticatorProvider, code);
}

if (reauthenticated == false)
{
return false;
}

var disableResult = await _userManager.SetTwoFactorEnabledAsync(user, false);
if (disableResult.Succeeded == false)
{
return false;
}

await _userManager.ResetAuthenticatorKeyAsync(user);
var recoveryCodes = await _dbContext.RecoveryCodes.Where(recoveryCode => recoveryCode.UserId == user.Id).ToListAsync(cancellationToken);
_dbContext.RecoveryCodes.RemoveRange(recoveryCodes);
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}

public async Task RevokeRecoveryCodesAsync(ApplicationUser user, CancellationToken cancellationToken)
{
var recoveryCodes = await _dbContext.RecoveryCodes
.Where(recoveryCode => recoveryCode.UserId == user.Id)
.ToListAsync(cancellationToken);
_dbContext.RecoveryCodes.RemoveRange(recoveryCodes);
await _dbContext.SaveChangesAsync(cancellationToken);
}
}
115 changes: 115 additions & 0 deletions SecurityService.BusinessLogic/Mfa/MfaPolicyService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SecurityService.Database.DbContexts;
using SecurityService.Database.Entities;
using SimpleResults;

namespace SecurityService.BusinessLogic.Mfa;

public interface IMfaPolicyService
{
Task<bool> IsMfaRequiredAsync(ApplicationUser user, CancellationToken cancellationToken);
}

public sealed class MfaPolicyService : IMfaPolicyService
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly SecurityServiceDbContext _dbContext;

public MfaPolicyService(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
SecurityServiceDbContext dbContext)
{
_userManager = userManager;
_roleManager = roleManager;
_dbContext = dbContext;
}

public async Task<bool> IsMfaRequiredAsync(ApplicationUser user, CancellationToken cancellationToken)
{
var hasUserPolicy = await _dbContext.MfaPolicies
.AnyAsync(policy => policy.TargetType == MfaPolicyTargetType.User && policy.TargetId == user.Id, cancellationToken);
if (hasUserPolicy)
{
return true;
}

var roleNames = await _userManager.GetRolesAsync(user);
if (roleNames.Count == 0)
{
return false;
}

var roleIds = await _roleManager.Roles
.Where(role => role.Name != null && roleNames.Contains(role.Name))
.Select(role => role.Id)
.ToListAsync(cancellationToken);

return await _dbContext.MfaPolicies
.AnyAsync(policy => policy.TargetType == MfaPolicyTargetType.Role && roleIds.Contains(policy.TargetId), cancellationToken);
}

public async Task<Result> RequireUserAsync(string userId, CancellationToken cancellationToken)
{
if (await _userManager.FindByIdAsync(userId) is null)
{
return Result.NotFound($"No user found with id '{userId}'.");
}

return await RequireAsync(MfaPolicyTargetType.User, userId, cancellationToken);
}

public Task<Result> RemoveUserAsync(string userId, CancellationToken cancellationToken) =>
RemoveAsync(MfaPolicyTargetType.User, userId, cancellationToken);

public async Task<Result> RequireRoleAsync(string roleId, CancellationToken cancellationToken)
{
if (await _roleManager.FindByIdAsync(roleId) is null)
{
return Result.NotFound($"No role found with id '{roleId}'.");
}

return await RequireAsync(MfaPolicyTargetType.Role, roleId, cancellationToken);
}

public Task<Result> RemoveRoleAsync(string roleId, CancellationToken cancellationToken) =>
RemoveAsync(MfaPolicyTargetType.Role, roleId, cancellationToken);

private async Task<Result> RequireAsync(MfaPolicyTargetType targetType, string targetId, CancellationToken cancellationToken)
{
var exists = await _dbContext.MfaPolicies.AnyAsync(
policy => policy.TargetType == targetType && policy.TargetId == targetId,
cancellationToken);
if (exists)
{
return Result.Success();
}

_dbContext.MfaPolicies.Add(new MfaPolicy
{
Id = Guid.NewGuid(),
TargetType = targetType,
TargetId = targetId,
CreatedUtc = DateTime.UtcNow
});
await _dbContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}

private async Task<Result> RemoveAsync(MfaPolicyTargetType targetType, string targetId, CancellationToken cancellationToken)
{
var policies = await _dbContext.MfaPolicies
.Where(policy => policy.TargetType == targetType && policy.TargetId == targetId)
.ToListAsync(cancellationToken);
if (policies.Count == 0)
{
return Result.Success();
}

_dbContext.MfaPolicies.RemoveRange(policies);
await _dbContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
Loading
Loading