diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2fb46aac..8648223c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -30,6 +30,7 @@
+
@@ -56,4 +57,4 @@
-
\ No newline at end of file
+
diff --git a/SecurityService.BusinessLogic/Mfa/LoginMfaModels.cs b/SecurityService.BusinessLogic/Mfa/LoginMfaModels.cs
new file mode 100644
index 00000000..5c38c58c
--- /dev/null
+++ b/SecurityService.BusinessLogic/Mfa/LoginMfaModels.cs
@@ -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(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(payload);
+ return transaction is not null && transaction.ExpiresUtc > DateTimeOffset.UtcNow;
+ }
+ catch (Exception) when (value.Length > 0)
+ {
+ return false;
+ }
+ }
+}
diff --git a/SecurityService.BusinessLogic/Mfa/MfaAccountService.cs b/SecurityService.BusinessLogic/Mfa/MfaAccountService.cs
new file mode 100644
index 00000000..fa52a7df
--- /dev/null
+++ b/SecurityService.BusinessLogic/Mfa/MfaAccountService.cs
@@ -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 _userManager;
+ private readonly IPasswordHasher _passwordHasher;
+ private readonly SecurityServiceDbContext _dbContext;
+ private readonly RecoveryCodeGenerator _recoveryCodeGenerator;
+
+ public MfaAccountService(
+ UserManager userManager,
+ IPasswordHasher passwordHasher,
+ SecurityServiceDbContext dbContext,
+ RecoveryCodeGenerator recoveryCodeGenerator)
+ {
+ _userManager = userManager;
+ _passwordHasher = passwordHasher;
+ _dbContext = dbContext;
+ _recoveryCodeGenerator = recoveryCodeGenerator;
+ }
+
+ public async Task 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 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 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> 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 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);
+ return true;
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+ public async Task 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);
+ }
+}
diff --git a/SecurityService.BusinessLogic/Mfa/MfaPolicyService.cs b/SecurityService.BusinessLogic/Mfa/MfaPolicyService.cs
new file mode 100644
index 00000000..171cc643
--- /dev/null
+++ b/SecurityService.BusinessLogic/Mfa/MfaPolicyService.cs
@@ -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 IsMfaRequiredAsync(ApplicationUser user, CancellationToken cancellationToken);
+}
+
+public sealed class MfaPolicyService : IMfaPolicyService
+{
+ private readonly UserManager _userManager;
+ private readonly RoleManager _roleManager;
+ private readonly SecurityServiceDbContext _dbContext;
+
+ public MfaPolicyService(
+ UserManager userManager,
+ RoleManager roleManager,
+ SecurityServiceDbContext dbContext)
+ {
+ _userManager = userManager;
+ _roleManager = roleManager;
+ _dbContext = dbContext;
+ }
+
+ public async Task 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 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 RemoveUserAsync(string userId, CancellationToken cancellationToken) =>
+ RemoveAsync(MfaPolicyTargetType.User, userId, cancellationToken);
+
+ public async Task 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 RemoveRoleAsync(string roleId, CancellationToken cancellationToken) =>
+ RemoveAsync(MfaPolicyTargetType.Role, roleId, cancellationToken);
+
+ private async Task 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 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();
+ }
+}
diff --git a/SecurityService.BusinessLogic/Mfa/RecoveryCodeGenerator.cs b/SecurityService.BusinessLogic/Mfa/RecoveryCodeGenerator.cs
new file mode 100644
index 00000000..20196844
--- /dev/null
+++ b/SecurityService.BusinessLogic/Mfa/RecoveryCodeGenerator.cs
@@ -0,0 +1,30 @@
+using System.Security.Cryptography;
+
+namespace SecurityService.BusinessLogic.Mfa;
+
+public sealed class RecoveryCodeGenerator
+{
+ private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+
+ public IReadOnlyList Generate(int count)
+ {
+ if (count <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(count));
+ }
+
+ var codes = new List(count);
+ for (var codeIndex = 0; codeIndex < count; codeIndex++)
+ {
+ var characters = new char[16];
+ for (var characterIndex = 0; characterIndex < characters.Length; characterIndex++)
+ {
+ characters[characterIndex] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
+ }
+
+ codes.Add(new string(characters));
+ }
+
+ return codes;
+ }
+}
diff --git a/SecurityService.BusinessLogic/Oidc/OAuthGrantPolicy.cs b/SecurityService.BusinessLogic/Oidc/OAuthGrantPolicy.cs
index 5c6ed4b5..fd17531b 100644
--- a/SecurityService.BusinessLogic/Oidc/OAuthGrantPolicy.cs
+++ b/SecurityService.BusinessLogic/Oidc/OAuthGrantPolicy.cs
@@ -31,6 +31,11 @@ public static bool IsGrantAllowed(string grantType, string clientId, OAuthOption
return true;
}
+ if (string.Equals(grantType, "hybrid", StringComparison.OrdinalIgnoreCase) && options.EnableHybridFlow == false)
+ {
+ return false;
+ }
+
if (LegacyGrantTypes.Contains(grantType) == false || options.LegacyGrantTypeClients.TryGetValue(grantType, out List? clients) == false)
{
return false;
diff --git a/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs b/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs
index 2103e1fa..7d16c185 100644
--- a/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs
+++ b/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs
@@ -13,6 +13,7 @@
using OpenIddict.Validation.AspNetCore;
using SecurityService.Database.DbContexts;
using SecurityService.Database.Entities;
+using SecurityService.BusinessLogic.Mfa;
using SimpleResults;
using static OpenIddict.Abstractions.OpenIddictConstants;
@@ -31,6 +32,7 @@ public sealed class OidcRequestHandler :
private readonly IOpenIddictScopeManager _scopeManager;
private readonly SecurityServiceDbContext _dbContext;
private readonly ConsentTransactionProtector _consentTransactionProtector;
+ private readonly IMfaPolicyService _mfaPolicyService;
public OidcRequestHandler(
UserManager userManager,
@@ -39,7 +41,8 @@ public OidcRequestHandler(
IOpenIddictAuthorizationManager authorizationManager,
IOpenIddictScopeManager scopeManager,
SecurityServiceDbContext dbContext,
- ConsentTransactionProtector consentTransactionProtector)
+ ConsentTransactionProtector consentTransactionProtector,
+ IMfaPolicyService mfaPolicyService)
{
this._userManager = userManager;
this._signInManager = signInManager;
@@ -48,6 +51,7 @@ public OidcRequestHandler(
this._scopeManager = scopeManager;
this._dbContext = dbContext;
this._consentTransactionProtector = consentTransactionProtector;
+ this._mfaPolicyService = mfaPolicyService;
}
public async Task> Handle(OidcCommands.AuthorizeCommand command, CancellationToken cancellationToken)
@@ -270,6 +274,12 @@ private async Task HandlePasswordGrant(OpenIddictRequest req
return InvalidGrant();
}
+ if (await this._mfaPolicyService.IsMfaRequiredAsync(user, cancellationToken) ||
+ await this._userManager.GetTwoFactorEnabledAsync(user))
+ {
+ return InvalidGrant("MFA is required for this account. Use the interactive authorization-code flow.");
+ }
+
var signInResult = await this._signInManager.CheckPasswordSignInAsync(user, request.Password!, lockoutOnFailure: true);
if (signInResult.Succeeded == false)
{
diff --git a/SecurityService.BusinessLogic/RequestHandlers/LoginRequestHandler.cs b/SecurityService.BusinessLogic/RequestHandlers/LoginRequestHandler.cs
index a0e3d51a..ac79fecb 100644
--- a/SecurityService.BusinessLogic/RequestHandlers/LoginRequestHandler.cs
+++ b/SecurityService.BusinessLogic/RequestHandlers/LoginRequestHandler.cs
@@ -1,5 +1,7 @@
using MediatR;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
+using SecurityService.BusinessLogic.Mfa;
using SecurityService.BusinessLogic.Requests;
using SecurityService.Database.Entities;
using SecurityService.Models;
@@ -9,13 +11,30 @@ namespace SecurityService.BusinessLogic.RequestHandlers;
public sealed class LoginRequestHandler :
IRequestHandler>>,
- IRequestHandler
+ IRequestHandler,
+ IRequestHandler>,
+ IRequestHandler>
{
private readonly SignInManager SignInManager;
+ private readonly UserManager UserManager;
+ private readonly IMfaPolicyService MfaPolicyService;
+ private readonly MfaAccountService MfaAccountService;
+ private readonly MfaSignInTransactionProtector TransactionProtector;
+ private readonly MfaEnrollmentTransactionProtector EnrollmentTransactionProtector;
- public LoginRequestHandler(SignInManager signInManager)
+ public LoginRequestHandler(SignInManager signInManager,
+ UserManager userManager,
+ IMfaPolicyService mfaPolicyService,
+ MfaAccountService mfaAccountService,
+ MfaSignInTransactionProtector transactionProtector,
+ MfaEnrollmentTransactionProtector enrollmentTransactionProtector)
{
this.SignInManager = signInManager;
+ this.UserManager = userManager;
+ this.MfaPolicyService = mfaPolicyService;
+ this.MfaAccountService = mfaAccountService;
+ this.TransactionProtector = transactionProtector;
+ this.EnrollmentTransactionProtector = enrollmentTransactionProtector;
}
public async Task>> Handle(SecurityServiceQueries.GetExternalProvidersQuery query, CancellationToken cancellationToken)
@@ -54,4 +73,98 @@ public async Task Handle(SecurityServiceCommands.LoginCommand command, C
return Result.Failure("Invalid username or password.");
}
+
+ public async Task> Handle(SecurityServiceCommands.BeginLoginCommand command, CancellationToken cancellationToken)
+ {
+ var user = await this.UserManager.FindByNameAsync(command.Username);
+ if (user is null)
+ {
+ return Result.Success(new LoginRejected("Invalid username or password."));
+ }
+
+ var policyRequiresMfa = await this.MfaPolicyService.IsMfaRequiredAsync(user, cancellationToken);
+ var mfaEnabled = await this.UserManager.GetTwoFactorEnabledAsync(user);
+
+ if (mfaEnabled)
+ {
+ var passwordResult = await this.SignInManager.PasswordSignInAsync(user, command.Password, command.RememberLogin, lockoutOnFailure: true);
+ if (passwordResult.Succeeded)
+ {
+ return Result.Success(new LoginCompleted(command.ReturnUrl));
+ }
+
+ if (passwordResult.IsLockedOut)
+ {
+ return Result.Success(new LoginRejected("Your account has been locked. Please try again later or contact support."));
+ }
+
+ if (passwordResult.IsNotAllowed)
+ {
+ return Result.Success(new LoginRejected("You are not allowed to sign in. Please confirm your email address."));
+ }
+
+ if (passwordResult.RequiresTwoFactor)
+ {
+ var transaction = this.TransactionProtector.Protect(user.Id, command.ReturnUrl, command.RememberLogin);
+ return Result.Success(new LoginRequiresMfa(transaction));
+ }
+
+ return Result.Success(new LoginRejected("Invalid username or password."));
+ }
+
+ var passwordCheck = await this.SignInManager.CheckPasswordSignInAsync(user, command.Password, lockoutOnFailure: true);
+ if (passwordCheck.Succeeded == false)
+ {
+ if (passwordCheck.IsLockedOut)
+ {
+ return Result.Success(new LoginRejected("Your account has been locked. Please try again later or contact support."));
+ }
+
+ if (passwordCheck.IsNotAllowed)
+ {
+ return Result.Success(new LoginRejected("You are not allowed to sign in. Please confirm your email address."));
+ }
+
+ return Result.Success(new LoginRejected("Invalid username or password."));
+ }
+
+ if (policyRequiresMfa)
+ {
+ var transaction = this.EnrollmentTransactionProtector.Protect(user.Id, command.ReturnUrl);
+ return Result.Success(new LoginRequiresMfaEnrollment(transaction));
+ }
+
+ await this.SignInManager.SignInAsync(user, command.RememberLogin);
+ return Result.Success(new LoginCompleted(command.ReturnUrl));
+ }
+
+ public async Task> Handle(SecurityServiceCommands.CompleteMfaLoginCommand command, CancellationToken cancellationToken)
+ {
+ if (this.TransactionProtector.TryUnprotect(command.Transaction, out var transaction) == false || transaction is null)
+ {
+ return Result.Success(new LoginRejected("The MFA sign-in request has expired. Start signing in again."));
+ }
+
+ var user = await this.SignInManager.GetTwoFactorAuthenticationUserAsync();
+ if (user is null || !string.Equals(user.Id, transaction.UserId, StringComparison.Ordinal) || await this.UserManager.GetTwoFactorEnabledAsync(user) == false || await this.UserManager.IsLockedOutAsync(user))
+ {
+ return Result.Success(new LoginRejected("The MFA sign-in request is no longer valid."));
+ }
+
+ var valid = await this.UserManager.VerifyTwoFactorTokenAsync(user, "Authenticator", command.Code);
+ if (valid == false)
+ {
+ valid = await this.MfaAccountService.RedeemRecoveryCodeAsync(user, command.Code, cancellationToken);
+ }
+ if (valid == false)
+ {
+ await this.UserManager.AccessFailedAsync(user);
+ return Result.Success(new LoginRejected("The authenticator or recovery code was invalid."));
+ }
+
+ await this.UserManager.ResetAccessFailedCountAsync(user);
+ await this.SignInManager.Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme);
+ await this.SignInManager.SignInAsync(user, transaction.RememberLogin);
+ return Result.Success(new LoginCompleted(transaction.ReturnUrl));
+ }
}
diff --git a/SecurityService.BusinessLogic/RequestHandlers/MfaPolicyRequestHandler.cs b/SecurityService.BusinessLogic/RequestHandlers/MfaPolicyRequestHandler.cs
new file mode 100644
index 00000000..537f66f7
--- /dev/null
+++ b/SecurityService.BusinessLogic/RequestHandlers/MfaPolicyRequestHandler.cs
@@ -0,0 +1,32 @@
+using MediatR;
+using SecurityService.BusinessLogic.Mfa;
+using SecurityService.BusinessLogic.Requests;
+using SimpleResults;
+
+namespace SecurityService.BusinessLogic.RequestHandlers;
+
+public sealed class MfaPolicyRequestHandler :
+ IRequestHandler,
+ IRequestHandler,
+ IRequestHandler,
+ IRequestHandler
+{
+ private readonly MfaPolicyService _mfaPolicyService;
+
+ public MfaPolicyRequestHandler(MfaPolicyService mfaPolicyService)
+ {
+ _mfaPolicyService = mfaPolicyService;
+ }
+
+ public Task Handle(SecurityServiceCommands.RequireUserMfaCommand command, CancellationToken cancellationToken) =>
+ _mfaPolicyService.RequireUserAsync(command.UserId, cancellationToken);
+
+ public Task Handle(SecurityServiceCommands.RemoveUserMfaCommand command, CancellationToken cancellationToken) =>
+ _mfaPolicyService.RemoveUserAsync(command.UserId, cancellationToken);
+
+ public Task Handle(SecurityServiceCommands.RequireRoleMfaCommand command, CancellationToken cancellationToken) =>
+ _mfaPolicyService.RequireRoleAsync(command.RoleId, cancellationToken);
+
+ public Task Handle(SecurityServiceCommands.RemoveRoleMfaCommand command, CancellationToken cancellationToken) =>
+ _mfaPolicyService.RemoveRoleAsync(command.RoleId, cancellationToken);
+}
diff --git a/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs b/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs
index 09d322cf..9a4f76bd 100644
--- a/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs
+++ b/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs
@@ -1,4 +1,5 @@
using MediatR;
+using SecurityService.BusinessLogic.Mfa;
using SecurityService.Models;
using SimpleResults;
@@ -69,5 +70,12 @@ public record ProcessPasswordResetConfirmationCommand(String Username,
String ClientId) : IRequest>;
public record LoginCommand(string Username, string Password, bool RememberLogin) : IRequest;
+ public record BeginLoginCommand(string Username, string Password, bool RememberLogin, string ReturnUrl) : IRequest>;
+ public record CompleteMfaLoginCommand(string Transaction, string Code) : IRequest>;
public record RevokeGrantCommand(string UserId, string AuthorizationId) : IRequest;
+
+ public record RequireUserMfaCommand(String UserId) : IRequest;
+ public record RemoveUserMfaCommand(String UserId) : IRequest;
+ public record RequireRoleMfaCommand(String RoleId) : IRequest;
+ public record RemoveRoleMfaCommand(String RoleId) : IRequest;
}
diff --git a/SecurityService.BusinessLogic/SecurityServiceOptions.cs b/SecurityService.BusinessLogic/SecurityServiceOptions.cs
index 8d24f345..ad009f36 100644
--- a/SecurityService.BusinessLogic/SecurityServiceOptions.cs
+++ b/SecurityService.BusinessLogic/SecurityServiceOptions.cs
@@ -40,6 +40,8 @@ public ServiceOptions()
public String PublicOrigin { get; set; } = string.Empty;
+ public String DataProtectionKeyDirectory { get; set; } = string.Empty;
+
public SignInOptions SignInOptions { get; set; }
public TokenOptions TokenOptions { get; set; }
@@ -57,6 +59,8 @@ public ServiceOptions()
public sealed class OAuthOptions
{
+ public bool EnableHybridFlow { get; set; }
+
public Dictionary> LegacyGrantTypeClients { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
diff --git a/SecurityService.Client/ISecurityServiceClient.cs b/SecurityService.Client/ISecurityServiceClient.cs
index ea291b44..62d4d970 100644
--- a/SecurityService.Client/ISecurityServiceClient.cs
+++ b/SecurityService.Client/ISecurityServiceClient.cs
@@ -33,6 +33,11 @@ Task CreateRole(CreateRoleRequest createRoleRequest,
Task CreateUser(CreateUserRequest createUserRequest,
CancellationToken cancellationToken);
+ Task RequireUserMfa(String userId, CancellationToken cancellationToken);
+ Task RemoveUserMfa(String userId, CancellationToken cancellationToken);
+ Task RequireRoleMfa(String roleId, CancellationToken cancellationToken);
+ Task RemoveRoleMfa(String roleId, CancellationToken cancellationToken);
+
Task> GetApiResource(String apiResourceName,
CancellationToken cancellationToken);
diff --git a/SecurityService.Client/SecurityServiceClient.cs b/SecurityService.Client/SecurityServiceClient.cs
index 1615bb2d..ce86d922 100644
--- a/SecurityService.Client/SecurityServiceClient.cs
+++ b/SecurityService.Client/SecurityServiceClient.cs
@@ -207,6 +207,26 @@ public async Task CreateUser(CreateUserRequest createUserRequest,
}
}
+ public async Task RequireUserMfa(String userId, CancellationToken cancellationToken)
+ {
+ return await this.Put(this.BuildRequestUrl($"/api/users/{userId}/mfa-policy"), new { }, cancellationToken);
+ }
+
+ public async Task RemoveUserMfa(String userId, CancellationToken cancellationToken)
+ {
+ return await this.Delete(this.BuildRequestUrl($"/api/users/{userId}/mfa-policy"), cancellationToken);
+ }
+
+ public async Task RequireRoleMfa(String roleId, CancellationToken cancellationToken)
+ {
+ return await this.Put(this.BuildRequestUrl($"/api/roles/{roleId}/mfa-policy"), new { }, cancellationToken);
+ }
+
+ public async Task RemoveRoleMfa(String roleId, CancellationToken cancellationToken)
+ {
+ return await this.Delete(this.BuildRequestUrl($"/api/roles/{roleId}/mfa-policy"), cancellationToken);
+ }
+
public async Task> GetApiResource(String apiResourceName,
CancellationToken cancellationToken)
{
diff --git a/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs b/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs
index 24007b4b..a8a74af8 100644
--- a/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs
+++ b/SecurityService.Database/DbContexts/SecurityServiceDbContext.cs
@@ -17,6 +17,10 @@ public SecurityServiceDbContext(DbContextOptions optio
public DbSet ResourceDefinitions => this.Set();
+ public DbSet MfaPolicies => this.Set();
+
+ public DbSet RecoveryCodes => this.Set();
+
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
@@ -39,5 +43,27 @@ protected override void OnModelCreating(ModelBuilder builder)
entity.Property(resource => resource.Name).HasMaxLength(200);
entity.Property(resource => resource.Type).HasConversion(new EnumToStringConverter());
});
+
+ builder.Entity(entity =>
+ {
+ entity.HasKey(policy => policy.Id);
+ entity.Property(policy => policy.TargetType).HasConversion();
+ entity.Property(policy => policy.TargetId).HasMaxLength(450).IsRequired();
+ entity.HasIndex(policy => new { policy.TargetType, policy.TargetId }).IsUnique();
+ });
+
+ builder.Entity(entity =>
+ {
+ entity.HasKey(code => code.Id);
+ entity.Property(code => code.UserId).HasMaxLength(450).IsRequired();
+ entity.Property(code => code.CodeHash).HasMaxLength(200).IsRequired();
+ entity.Property(code => code.ConcurrencyStamp).HasMaxLength(32).IsConcurrencyToken().IsRequired();
+ entity.HasIndex(code => new { code.UserId, code.CodeHash }).IsUnique();
+ entity.HasIndex(code => new { code.UserId, code.ConsumedUtc });
+ entity.HasOne()
+ .WithMany()
+ .HasForeignKey(code => code.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
}
}
diff --git a/SecurityService.Database/Entities/MfaPolicy.cs b/SecurityService.Database/Entities/MfaPolicy.cs
new file mode 100644
index 00000000..dc7ca520
--- /dev/null
+++ b/SecurityService.Database/Entities/MfaPolicy.cs
@@ -0,0 +1,18 @@
+namespace SecurityService.Database.Entities;
+
+public enum MfaPolicyTargetType
+{
+ User = 1,
+ Role = 2
+}
+
+public sealed class MfaPolicy
+{
+ public Guid Id { get; set; }
+
+ public MfaPolicyTargetType TargetType { get; set; }
+
+ public string TargetId { get; set; } = string.Empty;
+
+ public DateTime CreatedUtc { get; set; }
+}
diff --git a/SecurityService.Database/Entities/RecoveryCode.cs b/SecurityService.Database/Entities/RecoveryCode.cs
new file mode 100644
index 00000000..05078398
--- /dev/null
+++ b/SecurityService.Database/Entities/RecoveryCode.cs
@@ -0,0 +1,16 @@
+namespace SecurityService.Database.Entities;
+
+public sealed class RecoveryCode
+{
+ public Guid Id { get; set; }
+
+ public string UserId { get; set; } = string.Empty;
+
+ public string CodeHash { get; set; } = string.Empty;
+
+ public string ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString("N");
+
+ public DateTime CreatedUtc { get; set; }
+
+ public DateTime? ConsumedUtc { get; set; }
+}
diff --git a/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.Designer.cs b/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.Designer.cs
new file mode 100644
index 00000000..cdd897dd
--- /dev/null
+++ b/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.Designer.cs
@@ -0,0 +1,714 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SecurityService.Database.DbContexts;
+
+#nullable disable
+
+namespace SecurityService.Database.Migrations
+{
+ [DbContext(typeof(SecurityServiceDbContext))]
+ [Migration("20261006155308_AddMfaSupport")]
+ partial class AddMfaSupport
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.12")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex")
+ .HasFilter("[NormalizedName] IS NOT NULL");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("RoleId")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ApplicationType")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("ClientId")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ClientSecret")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClientType")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("ConcurrencyToken")
+ .IsConcurrencyToken()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("ConsentType")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("DisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayNames")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("JsonWebKeySet")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Permissions")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PostLogoutRedirectUris")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Properties")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RedirectUris")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Requirements")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Settings")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId")
+ .IsUnique()
+ .HasFilter("[ClientId] IS NOT NULL");
+
+ b.ToTable("OpenIddictApplications", (string)null);
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ApplicationId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyToken")
+ .IsConcurrencyToken()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime2");
+
+ b.Property("Properties")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Scopes")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Status")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Subject")
+ .HasMaxLength(400)
+ .HasColumnType("nvarchar(400)");
+
+ b.Property("Type")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicationId", "Status", "Subject", "Type");
+
+ b.ToTable("OpenIddictAuthorizations", (string)null);
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyToken")
+ .IsConcurrencyToken()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Descriptions")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayNames")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Properties")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Resources")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasFilter("[Name] IS NOT NULL");
+
+ b.ToTable("OpenIddictScopes", (string)null);
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ApplicationId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("AuthorizationId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyToken")
+ .IsConcurrencyToken()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetime2");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("datetime2");
+
+ b.Property("Payload")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Properties")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RedemptionDate")
+ .HasColumnType("datetime2");
+
+ b.Property("ReferenceId")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Status")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Subject")
+ .HasMaxLength(400)
+ .HasColumnType("nvarchar(400)");
+
+ b.Property("Type")
+ .HasMaxLength(150)
+ .HasColumnType("nvarchar(150)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorizationId");
+
+ b.HasIndex("ReferenceId")
+ .IsUnique()
+ .HasFilter("[ReferenceId] IS NOT NULL");
+
+ b.HasIndex("ApplicationId", "Status", "Subject", "Type");
+
+ b.ToTable("OpenIddictTokens", (string)null);
+ });
+
+ modelBuilder.Entity("SecurityService.Database.Entities.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("int");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("FamilyName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("GivenName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("MiddleName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("RegistrationDateTime")
+ .HasColumnType("datetime2");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("bit");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex")
+ .HasFilter("[NormalizedUserName] IS NOT NULL");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("SecurityService.Database.Entities.ClientDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("AllowOfflineAccess")
+ .HasColumnType("bit");
+
+ b.Property("AllowedGrantTypesJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AllowedScopesJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ClientName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ClientType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("ClientUri")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PostLogoutRedirectUrisJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RedirectUrisJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RequireConsent")
+ .HasColumnType("bit");
+
+ b.Property("SecretHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId")
+ .IsUnique();
+
+ b.ToTable("ClientDefinitions");
+ });
+
+ modelBuilder.Entity("SecurityService.Database.Entities.MfaPolicy", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("TargetId")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("TargetType")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TargetType", "TargetId")
+ .IsUnique();
+
+ b.ToTable("MfaPolicies");
+ });
+
+ modelBuilder.Entity("SecurityService.Database.Entities.RecoveryCode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CodeHash")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ConsumedUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "CodeHash")
+ .IsUnique();
+
+ b.HasIndex("UserId", "ConsumedUtc");
+
+ b.ToTable("RecoveryCodes");
+ });
+
+ modelBuilder.Entity("SecurityService.Database.Entities.ResourceDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ClaimsJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Emphasize")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Required")
+ .HasColumnType("bit");
+
+ b.Property("ScopesJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("SecretHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ShowInDiscoveryDocument")
+ .HasColumnType("bit");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name", "Type")
+ .IsUnique();
+
+ b.ToTable("ResourceDefinitions");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("SecurityService.Database.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("SecurityService.Database.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("SecurityService.Database.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("SecurityService.Database.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
+ {
+ b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
+ .WithMany("Authorizations")
+ .HasForeignKey("ApplicationId");
+
+ b.Navigation("Application");
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
+ {
+ b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
+ .WithMany("Tokens")
+ .HasForeignKey("ApplicationId");
+
+ b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization")
+ .WithMany("Tokens")
+ .HasForeignKey("AuthorizationId");
+
+ b.Navigation("Application");
+
+ b.Navigation("Authorization");
+ });
+
+ modelBuilder.Entity("SecurityService.Database.Entities.RecoveryCode", b =>
+ {
+ b.HasOne("SecurityService.Database.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
+ {
+ b.Navigation("Authorizations");
+
+ b.Navigation("Tokens");
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
+ {
+ b.Navigation("Tokens");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.cs b/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.cs
new file mode 100644
index 00000000..71aa6dec
--- /dev/null
+++ b/SecurityService.Database/Migrations/20261006155308_AddMfaSupport.cs
@@ -0,0 +1,77 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace SecurityService.Database.Migrations
+{
+ ///
+ public partial class AddMfaSupport : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "MfaPolicies",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ TargetType = table.Column(type: "nvarchar(450)", nullable: false),
+ TargetId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false),
+ CreatedUtc = table.Column(type: "datetime2", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_MfaPolicies", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "RecoveryCodes",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false),
+ CodeHash = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false),
+ CreatedUtc = table.Column(type: "datetime2", nullable: false),
+ ConsumedUtc = table.Column(type: "datetime2", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_RecoveryCodes", x => x.Id);
+ table.ForeignKey(
+ name: "FK_RecoveryCodes_AspNetUsers_UserId",
+ column: x => x.UserId,
+ principalTable: "AspNetUsers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_MfaPolicies_TargetType_TargetId",
+ table: "MfaPolicies",
+ columns: new[] { "TargetType", "TargetId" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RecoveryCodes_UserId_CodeHash",
+ table: "RecoveryCodes",
+ columns: new[] { "UserId", "CodeHash" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RecoveryCodes_UserId_ConsumedUtc",
+ table: "RecoveryCodes",
+ columns: new[] { "UserId", "ConsumedUtc" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "MfaPolicies");
+
+ migrationBuilder.DropTable(
+ name: "RecoveryCodes");
+ }
+ }
+}
diff --git a/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.Designer.cs b/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.Designer.cs
new file mode 100644
index 00000000..177535b2
--- /dev/null
+++ b/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.Designer.cs
@@ -0,0 +1,720 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SecurityService.Database.DbContexts;
+
+#nullable disable
+
+namespace SecurityService.Database.Migrations
+{
+ [DbContext(typeof(SecurityServiceDbContext))]
+ [Migration("20261006160427_AddRecoveryCodeConcurrency")]
+ partial class AddRecoveryCodeConcurrency
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.12")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex")
+ .HasFilter("[NormalizedName] IS NOT NULL");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim