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", 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("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + 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/20261006160427_AddRecoveryCodeConcurrency.cs b/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.cs new file mode 100644 index 00000000..6ea02980 --- /dev/null +++ b/SecurityService.Database/Migrations/20261006160427_AddRecoveryCodeConcurrency.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecurityService.Database.Migrations +{ + /// + public partial class AddRecoveryCodeConcurrency : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ConcurrencyStamp", + table: "RecoveryCodes", + type: "nvarchar(32)", + maxLength: 32, + nullable: false, + defaultValueSql: "CONVERT(nvarchar(32), NEWID())"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ConcurrencyStamp", + table: "RecoveryCodes"); + } + } +} diff --git a/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs b/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs index b6943b92..89f8acec 100644 --- a/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs +++ b/SecurityService.Database/Migrations/SecurityServiceDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("ProductVersion", "10.0.12") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -366,7 +366,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("OpenIddictTokens", (string)null); }); - modelBuilder.Entity("SecurityService.Database.ApplicationUser", b => + modelBuilder.Entity("SecurityService.Database.Entities.ApplicationUser", b => { b.Property("Id") .HasColumnType("nvarchar(450)"); @@ -503,6 +503,70 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + 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") @@ -563,7 +627,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("SecurityService.Database.ApplicationUser", null) + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -572,7 +636,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("SecurityService.Database.ApplicationUser", null) + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -587,7 +651,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SecurityService.Database.ApplicationUser", null) + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -596,7 +660,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("SecurityService.Database.ApplicationUser", null) + b.HasOne("SecurityService.Database.Entities.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -627,6 +691,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); diff --git a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs index 76da2ac5..229b853a 100644 --- a/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs +++ b/SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs @@ -88,11 +88,14 @@ public async Task GetClientToken(String clientId, String secret, Cancell public async Task GetPasswordToken(String clientId, String secret, String userName, String password, CancellationToken cancellationToken) { - Result? tokenResponseResult = await this.SecurityServiceClient.GetToken(userName,password, clientId, secret, cancellationToken).ConfigureAwait(false); + Result? tokenResponseResult = await this.RequestPasswordToken(clientId, secret, userName, password, cancellationToken).ConfigureAwait(false); tokenResponseResult.IsSuccess.ShouldBeTrue(); return tokenResponseResult.Data.AccessToken; } + public Task> RequestPasswordToken(String clientId, String secret, String userName, String password, CancellationToken cancellationToken) => + this.SecurityServiceClient.GetToken(userName, password, clientId, secret, cancellationToken); + public async Task WhenIGetTheApiResourcesApiResourceDetailsAreReturnedAsFollows(List expectedDetails, CancellationToken cancellationToken){ Result>? apiResourceDetailsListResult = await this.SecurityServiceClient.GetApiResources(cancellationToken).ConfigureAwait(false); apiResourceDetailsListResult.IsSuccess.ShouldBeTrue(); @@ -296,6 +299,18 @@ public async Task WhenIGetTheRolesRolesDetailsAreReturnedAsFollows(List RequireUserMfa(String userId, CancellationToken cancellationToken) => + this.SecurityServiceClient.RequireUserMfa(userId, cancellationToken); + + public Task RemoveUserMfa(String userId, CancellationToken cancellationToken) => + this.SecurityServiceClient.RemoveUserMfa(userId, cancellationToken); + + public Task RequireRoleMfa(String roleId, CancellationToken cancellationToken) => + this.SecurityServiceClient.RequireRoleMfa(roleId, cancellationToken); + + public Task RemoveRoleMfa(String roleId, CancellationToken cancellationToken) => + this.SecurityServiceClient.RemoveRoleMfa(roleId, cancellationToken); + public async Task WhenIGetTheUsersUsersDetailsAreReturnedAsFollows(List expectedDetails, CancellationToken cancellationToken) { Result>? getUsersResult = await this.SecurityServiceClient.GetUsers(String.Empty, CancellationToken.None).ConfigureAwait(false); diff --git a/SecurityService.IntegrationTests/Common/DockerHelper.cs b/SecurityService.IntegrationTests/Common/DockerHelper.cs index 9e992284..e9666ea1 100644 --- a/SecurityService.IntegrationTests/Common/DockerHelper.cs +++ b/SecurityService.IntegrationTests/Common/DockerHelper.cs @@ -86,6 +86,7 @@ public override ContainerBuilder SetupSecurityServiceContainer() environmentVariables.Add("ServiceOptions:ManagementBootstrap:ClientSecret", "management-bootstrap-secret"); environmentVariables.Add("ServiceOptions:OAuth:LegacyGrantTypeClients:password:0", "merchantClient"); environmentVariables.Add("ServiceOptions:OAuth:LegacyGrantTypeClients:hybrid:0", "testclient2"); + environmentVariables.Add("ServiceOptions:OAuth:EnableHybridFlow", "true"); environmentVariables.Add("ConnectionStrings:PersistedGrantDbContext", this.SetConnectionString($"PersistedGrantStore-{this.TestId}", this.UseSecureSqlServerDatabase)); environmentVariables.Add("ConnectionStrings:ConfigurationDbContext", this.SetConnectionString($"Configuration-{this.TestId}", this.UseSecureSqlServerDatabase)); environmentVariables.Add("ConnectionStrings:AuthenticationDbContext", this.SetConnectionString($"Authentication-{this.TestId}", this.UseSecureSqlServerDatabase)); diff --git a/SecurityService.IntegrationTests/Common/TestingContext.cs b/SecurityService.IntegrationTests/Common/TestingContext.cs index c2a82559..ec5df2ba 100644 --- a/SecurityService.IntegrationTests/Common/TestingContext.cs +++ b/SecurityService.IntegrationTests/Common/TestingContext.cs @@ -1,5 +1,7 @@ using SecurityService.DataTransferObjects; +using SimpleResults; + namespace SecurityService.IntergrationTests.Common { using System; @@ -54,6 +56,8 @@ public class TestingContext /// public TokenResponse TokenResponse; public String AccessToken; + public Result LastMfaPolicyResult; + public Result LastTokenResult; public NlogLogger Logger; @@ -70,4 +74,4 @@ public TestingContext() this.IdentityResources = new List(); } } -} \ No newline at end of file +} diff --git a/SecurityService.IntegrationTests/Roles/Roles.feature b/SecurityService.IntegrationTests/Roles/Roles.feature index 63fdfa24..4506baa7 100644 --- a/SecurityService.IntegrationTests/Roles/Roles.feature +++ b/SecurityService.IntegrationTests/Roles/Roles.feature @@ -8,6 +8,15 @@ Scenario: Get Roles | TestRole1 | | TestRole2 | | TestRole3 | + +@mfa +Scenario: Require and remove MFA for a role + When I require MFA for role 'TestRole1' + Then the MFA policy operation succeeds + When I require MFA for role 'TestRole1' + Then the MFA policy operation succeeds + When I remove MFA for role 'TestRole1' + Then the MFA policy operation succeeds When I get the role with name 'TestRole1' the role details are returned as follows | Role Name | | TestRole1 | diff --git a/SecurityService.IntegrationTests/Roles/Roles.feature.cs b/SecurityService.IntegrationTests/Roles/Roles.feature.cs index 6ff7e1fd..bf89d4e1 100644 --- a/SecurityService.IntegrationTests/Roles/Roles.feature.cs +++ b/SecurityService.IntegrationTests/Roles/Roles.feature.cs @@ -109,7 +109,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() { - return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Roles/Roles.feature.ndjson", 3); + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Roles/Roles.feature.ndjson", 4); } [global::NUnit.Framework.TestAttribute()] @@ -134,48 +134,92 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa else { await this.ScenarioStartAsync(); - global::Reqnroll.Table table17 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table1 = new global::Reqnroll.Table(new string[] { "Role Name"}); - table17.AddRow(new string[] { + table1.AddRow(new string[] { "TestRole1"}); - table17.AddRow(new string[] { + table1.AddRow(new string[] { "TestRole2"}); - table17.AddRow(new string[] { + table1.AddRow(new string[] { "TestRole3"}); #line 6 - await testRunner.GivenAsync("I create the following roles", ((string)(null)), table17, "Given "); + await testRunner.GivenAsync("I create the following roles", ((string)(null)), table1, "Given "); #line hidden - global::Reqnroll.Table table18 = new global::Reqnroll.Table(new string[] { + } + await this.ScenarioCleanupAsync(); + } + + [global::NUnit.Framework.TestAttribute()] + [global::NUnit.Framework.DescriptionAttribute("Require and remove MFA for a role")] + [global::NUnit.Framework.CategoryAttribute("mfa")] + public async global::System.Threading.Tasks.Task RequireAndRemoveMFAForARole() + { + string[] tagsOfScenario = new string[] { + "mfa"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Require and remove MFA for a role", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 13 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 14 + await testRunner.WhenAsync("I require MFA for role \'TestRole1\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 15 + await testRunner.ThenAsync("the MFA policy operation succeeds", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 16 + await testRunner.WhenAsync("I require MFA for role \'TestRole1\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 17 + await testRunner.ThenAsync("the MFA policy operation succeeds", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 18 + await testRunner.WhenAsync("I remove MFA for role \'TestRole1\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 19 + await testRunner.ThenAsync("the MFA policy operation succeeds", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + global::Reqnroll.Table table2 = new global::Reqnroll.Table(new string[] { "Role Name"}); - table18.AddRow(new string[] { + table2.AddRow(new string[] { "TestRole1"}); -#line 11 - await testRunner.WhenAsync("I get the role with name \'TestRole1\' the role details are returned as follows", ((string)(null)), table18, "When "); +#line 20 + await testRunner.WhenAsync("I get the role with name \'TestRole1\' the role details are returned as follows", ((string)(null)), table2, "When "); #line hidden - global::Reqnroll.Table table19 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table3 = new global::Reqnroll.Table(new string[] { "Role Name"}); - table19.AddRow(new string[] { + table3.AddRow(new string[] { "TestRole2"}); -#line 14 - await testRunner.WhenAsync("I get the role with name \'TestRole2\' the role details are returned as follows", ((string)(null)), table19, "When "); +#line 23 + await testRunner.WhenAsync("I get the role with name \'TestRole2\' the role details are returned as follows", ((string)(null)), table3, "When "); #line hidden - global::Reqnroll.Table table20 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table4 = new global::Reqnroll.Table(new string[] { "Role Name"}); - table20.AddRow(new string[] { + table4.AddRow(new string[] { "TestRole3"}); -#line 17 - await testRunner.WhenAsync("I get the role with name \'TestRole3\' the role details are returned as follows", ((string)(null)), table20, "When "); +#line 26 + await testRunner.WhenAsync("I get the role with name \'TestRole3\' the role details are returned as follows", ((string)(null)), table4, "When "); #line hidden - global::Reqnroll.Table table21 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table5 = new global::Reqnroll.Table(new string[] { "Role Name"}); - table21.AddRow(new string[] { + table5.AddRow(new string[] { "TestRole1"}); - table21.AddRow(new string[] { + table5.AddRow(new string[] { "TestRole2"}); - table21.AddRow(new string[] { + table5.AddRow(new string[] { "TestRole3"}); -#line 20 - await testRunner.WhenAsync("I get the roles 3 roles details are returned as follows", ((string)(null)), table21, "When "); +#line 29 + await testRunner.WhenAsync("I get the roles 3 roles details are returned as follows", ((string)(null)), table5, "When "); #line hidden } await this.ScenarioCleanupAsync(); diff --git a/SecurityService.IntegrationTests/Roles/RolesSteps.cs b/SecurityService.IntegrationTests/Roles/RolesSteps.cs index 9337cd5c..cc6100c7 100644 --- a/SecurityService.IntegrationTests/Roles/RolesSteps.cs +++ b/SecurityService.IntegrationTests/Roles/RolesSteps.cs @@ -66,5 +66,26 @@ public async Task WhenIGetTheRolesRolesDetailsAreReturnedAsFollows(Int32 numberO await this.SecurityServiceSteps.WhenIGetTheRolesRolesDetailsAreReturnedAsFollows(requests, CancellationToken.None); } + [When(@"I require MFA for role '(.*)'")] + public async Task WhenIRequireMfaForRole(String roleName) + { + String roleId = this.TestingContext.Roles.Single(r => r.Key == roleName).Value; + this.TestingContext.LastMfaPolicyResult = await this.SecurityServiceSteps.RequireRoleMfa(roleId, CancellationToken.None); + } + + [When(@"I remove MFA for role '(.*)'")] + public async Task WhenIRemoveMfaForRole(String roleName) + { + String roleId = this.TestingContext.Roles.Single(r => r.Key == roleName).Value; + this.TestingContext.LastMfaPolicyResult = await this.SecurityServiceSteps.RemoveRoleMfa(roleId, CancellationToken.None); + } + + [Then(@"the MFA policy operation succeeds")] + public void ThenTheMfaPolicyOperationSucceeds() + { + this.TestingContext.LastMfaPolicyResult.ShouldNotBeNull(); + this.TestingContext.LastMfaPolicyResult.IsSuccess.ShouldBeTrue(this.TestingContext.LastMfaPolicyResult.Message); + } + } } diff --git a/SecurityService.IntegrationTests/Token/Token.feature b/SecurityService.IntegrationTests/Token/Token.feature index d571a866..59b613ca 100644 --- a/SecurityService.IntegrationTests/Token/Token.feature +++ b/SecurityService.IntegrationTests/Token/Token.feature @@ -35,6 +35,15 @@ Scenario: Get Tokens | ClientId | ClientSecret | | serviceClient | Secret1 | Then my token is returned + +@mfa +Scenario: Password token is rejected when MFA is required + When I require MFA for user 'merchantuser' + Then the MFA policy operation succeeds + When I request a password token with the following values + | ClientId | ClientSecret | Username | Password | + | merchantClient | Secret1 | merchantuser | 123456 | + Then the password token request fails When I request a password token with the following values | ClientId | ClientSecret | Username | Password | | merchantClient | Secret1 | merchantuser | 123456 | diff --git a/SecurityService.IntegrationTests/Token/Token.feature.cs b/SecurityService.IntegrationTests/Token/Token.feature.cs index 78ab1a15..6582145b 100644 --- a/SecurityService.IntegrationTests/Token/Token.feature.cs +++ b/SecurityService.IntegrationTests/Token/Token.feature.cs @@ -224,7 +224,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() { - return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Token/Token.feature.ndjson", 3); + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Token/Token.feature.ndjson", 4); } [global::NUnit.Framework.TestAttribute()] @@ -263,6 +263,41 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa #line hidden #line 37 await testRunner.ThenAsync("my token is returned", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::NUnit.Framework.TestAttribute()] + [global::NUnit.Framework.DescriptionAttribute("Password token is rejected when MFA is required")] + [global::NUnit.Framework.CategoryAttribute("mfa")] + public async global::System.Threading.Tasks.Task PasswordTokenIsRejectedWhenMFAIsRequired() + { + string[] tagsOfScenario = new string[] { + "mfa"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Password token is rejected when MFA is required", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 40 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 +await this.FeatureBackgroundAsync(); +#line hidden +#line 41 + await testRunner.WhenAsync("I require MFA for user \'merchantuser\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 42 + await testRunner.ThenAsync("the MFA policy operation succeeds", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden global::Reqnroll.Table table7 = new global::Reqnroll.Table(new string[] { "ClientId", @@ -274,10 +309,26 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "Secret1", "merchantuser", "123456"}); -#line 38 +#line 43 await testRunner.WhenAsync("I request a password token with the following values", ((string)(null)), table7, "When "); #line hidden -#line 41 +#line 46 + await testRunner.ThenAsync("the password token request fails", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + global::Reqnroll.Table table8 = new global::Reqnroll.Table(new string[] { + "ClientId", + "ClientSecret", + "Username", + "Password"}); + table8.AddRow(new string[] { + "merchantClient", + "Secret1", + "merchantuser", + "123456"}); +#line 47 + await testRunner.WhenAsync("I request a password token with the following values", ((string)(null)), table8, "When "); +#line hidden +#line 50 await testRunner.ThenAsync("my token is returned", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden } diff --git a/SecurityService.IntegrationTests/Token/TokenSteps.cs b/SecurityService.IntegrationTests/Token/TokenSteps.cs index 5c7937fa..9a586e2b 100644 --- a/SecurityService.IntegrationTests/Token/TokenSteps.cs +++ b/SecurityService.IntegrationTests/Token/TokenSteps.cs @@ -49,9 +49,11 @@ public async Task WhenIRequestAPasswordTokenWithTheFollowingValues(Table table) String username = table.Rows[0]["Username"]; String password = table.Rows[0]["Password"]; - String tokenResponse = await this.SecurityServiceSteps.GetPasswordToken(clientId, clientSecret, username, password,CancellationToken.None); - - this.TestingContext.AccessToken = tokenResponse; + this.TestingContext.LastTokenResult = await this.SecurityServiceSteps.RequestPasswordToken(clientId, clientSecret, username, password, CancellationToken.None); + if (this.TestingContext.LastTokenResult.IsSuccess) + { + this.TestingContext.AccessToken = this.TestingContext.LastTokenResult.Data.AccessToken; + } } [Then(@"my token is returned")] @@ -60,5 +62,12 @@ public void ThenMyTokenIsReturned() this.TestingContext.AccessToken.ShouldNotBeNullOrEmpty(); } + [Then(@"the password token request fails")] + public void ThenThePasswordTokenRequestFails() + { + this.TestingContext.LastTokenResult.ShouldNotBeNull(); + this.TestingContext.LastTokenResult.IsSuccess.ShouldBeFalse(); + } + } } diff --git a/SecurityService.IntegrationTests/Users/Users.feature b/SecurityService.IntegrationTests/Users/Users.feature index c540f7d6..139fc75a 100644 --- a/SecurityService.IntegrationTests/Users/Users.feature +++ b/SecurityService.IntegrationTests/Users/Users.feature @@ -24,6 +24,18 @@ Scenario: Get Users When I get the user with user name 'testuser3@testing.co.uk' the user details are returned as follows | Email Address | Phone Number | Given Name | Middle Name | Family Name | Claims | Roles |RegistrationDate | | testuser3@testing.co.uk | 123456789 | Test | | User 3 | emailaddress:testuser3@testing.co.uk, givenname:Test, surname:User 3 | TestRole3 |Today | + +@mfa +Scenario: Require and remove MFA for a user + Given I create the following users + | Email Address | Phone Number | Given Name | Middle Name | Family Name | Claims | Roles | + | mfauser@testing.co.uk | 123456789 | MFA | | User | | TestRole1 | + When I require MFA for user 'mfauser@testing.co.uk' + Then the MFA policy operation succeeds + When I require MFA for user 'mfauser@testing.co.uk' + Then the MFA policy operation succeeds + When I remove MFA for user 'mfauser@testing.co.uk' + Then the MFA policy operation succeeds When I get the users 3 users details are returned as follows | Email Address | Phone Number | Given Name | Middle Name | Family Name | Claims | Roles |RegistrationDate | | testuser1@testing.co.uk | 123456789 | Test | | User 1 | emailaddress:testuser1@testing.co.uk, givenname:Test, surname:User 1 | TestRole1 |Today | diff --git a/SecurityService.IntegrationTests/Users/Users.feature.cs b/SecurityService.IntegrationTests/Users/Users.feature.cs index c73ac1ab..9814c9b6 100644 --- a/SecurityService.IntegrationTests/Users/Users.feature.cs +++ b/SecurityService.IntegrationTests/Users/Users.feature.cs @@ -113,22 +113,22 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { #line 4 #line hidden - global::Reqnroll.Table table29 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table1 = new global::Reqnroll.Table(new string[] { "Role Name"}); - table29.AddRow(new string[] { + table1.AddRow(new string[] { "TestRole1"}); - table29.AddRow(new string[] { + table1.AddRow(new string[] { "TestRole2"}); - table29.AddRow(new string[] { + table1.AddRow(new string[] { "TestRole3"}); #line 5 - await testRunner.GivenAsync("I create the following roles", ((string)(null)), table29, "Given "); + await testRunner.GivenAsync("I create the following roles", ((string)(null)), table1, "Given "); #line hidden } private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() { - return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Users/Users.feature.ndjson", 3); + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Users/Users.feature.ndjson", 4); } [global::NUnit.Framework.TestAttribute()] @@ -156,7 +156,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa #line 4 await this.FeatureBackgroundAsync(); #line hidden - global::Reqnroll.Table table30 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table2 = new global::Reqnroll.Table(new string[] { "Email Address", "Phone Number", "Given Name", @@ -164,7 +164,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "Family Name", "Claims", "Roles"}); - table30.AddRow(new string[] { + table2.AddRow(new string[] { "testuser1@testing.co.uk", "123456789", "Test", @@ -172,7 +172,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "User 1", "", "TestRole1"}); - table30.AddRow(new string[] { + table2.AddRow(new string[] { "testuser2@testing.co.uk", "123456789", "Test", @@ -180,7 +180,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "User 2", "", "TestRole2"}); - table30.AddRow(new string[] { + table2.AddRow(new string[] { "testuser3@testing.co.uk", "123456789", "Test", @@ -189,9 +189,9 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "", "TestRole3"}); #line 13 - await testRunner.GivenAsync("I create the following users", ((string)(null)), table30, "Given "); + await testRunner.GivenAsync("I create the following users", ((string)(null)), table2, "Given "); #line hidden - global::Reqnroll.Table table31 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table3 = new global::Reqnroll.Table(new string[] { "Email Address", "Phone Number", "Given Name", @@ -200,7 +200,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "Claims", "Roles", "RegistrationDate"}); - table31.AddRow(new string[] { + table3.AddRow(new string[] { "testuser1@testing.co.uk", "123456789", "Test", @@ -211,9 +211,9 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "Today"}); #line 18 await testRunner.WhenAsync("I get the user with user name \'testuser1@testing.co.uk\' the user details are retu" + - "rned as follows", ((string)(null)), table31, "When "); + "rned as follows", ((string)(null)), table3, "When "); #line hidden - global::Reqnroll.Table table32 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table4 = new global::Reqnroll.Table(new string[] { "Email Address", "Phone Number", "Given Name", @@ -222,7 +222,7 @@ await testRunner.WhenAsync("I get the user with user name \'testuser1@testing.co "Claims", "Roles", "RegistrationDate"}); - table32.AddRow(new string[] { + table4.AddRow(new string[] { "testuser2@testing.co.uk", "123456789", "Test", @@ -233,9 +233,9 @@ await testRunner.WhenAsync("I get the user with user name \'testuser1@testing.co "Today"}); #line 21 await testRunner.WhenAsync("I get the user with user name \'testuser2@testing.co.uk\' the user details are retu" + - "rned as follows", ((string)(null)), table32, "When "); + "rned as follows", ((string)(null)), table4, "When "); #line hidden - global::Reqnroll.Table table33 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table5 = new global::Reqnroll.Table(new string[] { "Email Address", "Phone Number", "Given Name", @@ -244,7 +244,7 @@ await testRunner.WhenAsync("I get the user with user name \'testuser2@testing.co "Claims", "Roles", "RegistrationDate"}); - table33.AddRow(new string[] { + table5.AddRow(new string[] { "testuser3@testing.co.uk", "123456789", "Test", @@ -255,9 +255,75 @@ await testRunner.WhenAsync("I get the user with user name \'testuser2@testing.co "Today"}); #line 24 await testRunner.WhenAsync("I get the user with user name \'testuser3@testing.co.uk\' the user details are retu" + - "rned as follows", ((string)(null)), table33, "When "); + "rned as follows", ((string)(null)), table5, "When "); #line hidden - global::Reqnroll.Table table34 = new global::Reqnroll.Table(new string[] { + } + await this.ScenarioCleanupAsync(); + } + + [global::NUnit.Framework.TestAttribute()] + [global::NUnit.Framework.DescriptionAttribute("Require and remove MFA for a user")] + [global::NUnit.Framework.CategoryAttribute("mfa")] + public async global::System.Threading.Tasks.Task RequireAndRemoveMFAForAUser() + { + string[] tagsOfScenario = new string[] { + "mfa"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Require and remove MFA for a user", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 29 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 +await this.FeatureBackgroundAsync(); +#line hidden + global::Reqnroll.Table table6 = new global::Reqnroll.Table(new string[] { + "Email Address", + "Phone Number", + "Given Name", + "Middle Name", + "Family Name", + "Claims", + "Roles"}); + table6.AddRow(new string[] { + "mfauser@testing.co.uk", + "123456789", + "MFA", + "", + "User", + "", + "TestRole1"}); +#line 30 + await testRunner.GivenAsync("I create the following users", ((string)(null)), table6, "Given "); +#line hidden +#line 33 + await testRunner.WhenAsync("I require MFA for user \'mfauser@testing.co.uk\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 34 + await testRunner.ThenAsync("the MFA policy operation succeeds", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 35 + await testRunner.WhenAsync("I require MFA for user \'mfauser@testing.co.uk\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 36 + await testRunner.ThenAsync("the MFA policy operation succeeds", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 37 + await testRunner.WhenAsync("I remove MFA for user \'mfauser@testing.co.uk\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 38 + await testRunner.ThenAsync("the MFA policy operation succeeds", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + global::Reqnroll.Table table7 = new global::Reqnroll.Table(new string[] { "Email Address", "Phone Number", "Given Name", @@ -266,7 +332,7 @@ await testRunner.WhenAsync("I get the user with user name \'testuser3@testing.co "Claims", "Roles", "RegistrationDate"}); - table34.AddRow(new string[] { + table7.AddRow(new string[] { "testuser1@testing.co.uk", "123456789", "Test", @@ -275,7 +341,7 @@ await testRunner.WhenAsync("I get the user with user name \'testuser3@testing.co "emailaddress:testuser1@testing.co.uk, givenname:Test, surname:User 1", "TestRole1", "Today"}); - table34.AddRow(new string[] { + table7.AddRow(new string[] { "testuser2@testing.co.uk", "123456789", "Test", @@ -284,7 +350,7 @@ await testRunner.WhenAsync("I get the user with user name \'testuser3@testing.co "emailaddress:testuser2@testing.co.uk, givenname:Test, surname:User 2", "TestRole2", "Today"}); - table34.AddRow(new string[] { + table7.AddRow(new string[] { "testuser3@testing.co.uk", "123456789", "Test", @@ -293,8 +359,8 @@ await testRunner.WhenAsync("I get the user with user name \'testuser3@testing.co "emailaddress:testuser3@testing.co.uk, givenname:Test, surname:User 3", "TestRole3", "Today"}); -#line 27 - await testRunner.WhenAsync("I get the users 3 users details are returned as follows", ((string)(null)), table34, "When "); +#line 39 + await testRunner.WhenAsync("I get the users 3 users details are returned as follows", ((string)(null)), table7, "When "); #line hidden } await this.ScenarioCleanupAsync(); diff --git a/SecurityService.IntegrationTests/Users/UsersSteps.cs b/SecurityService.IntegrationTests/Users/UsersSteps.cs index f8a232bb..d17f0419 100644 --- a/SecurityService.IntegrationTests/Users/UsersSteps.cs +++ b/SecurityService.IntegrationTests/Users/UsersSteps.cs @@ -84,7 +84,28 @@ public async Task WhenIGetTheUserWithUserNameTheUserDetailsAreReturnedAsFollows( List userDetailsList = table.Rows.ToUserResponses(); await this.SecurityServiceSteps.WhenIGetTheUserWithUserNameTheUserDetailsAreReturnedAsFollows(userDetailsList, userId, CancellationToken.None); } + + [When(@"I require MFA for user '(.*)'")] + public async Task WhenIRequireMfaForUser(String userName) + { + String userId = this.TestingContext.Users.Single(u => u.Key == userName).Value; + this.TestingContext.LastMfaPolicyResult = await this.SecurityServiceSteps.RequireUserMfa(userId, CancellationToken.None); + } + + [When(@"I remove MFA for user '(.*)'")] + public async Task WhenIRemoveMfaForUser(String userName) + { + String userId = this.TestingContext.Users.Single(u => u.Key == userName).Value; + this.TestingContext.LastMfaPolicyResult = await this.SecurityServiceSteps.RemoveUserMfa(userId, CancellationToken.None); + } + + [Then(@"the MFA policy operation succeeds")] + public void ThenTheMfaPolicyOperationSucceeds() + { + this.TestingContext.LastMfaPolicyResult.ShouldNotBeNull(); + this.TestingContext.LastMfaPolicyResult.IsSuccess.ShouldBeTrue(this.TestingContext.LastMfaPolicyResult.Message); + } #endregion } -} \ No newline at end of file +} diff --git a/SecurityService.OpenIdConnect.IntegrationTests/Common/TestingContext.cs b/SecurityService.OpenIdConnect.IntegrationTests/Common/TestingContext.cs index 33ad4206..c31067b7 100644 --- a/SecurityService.OpenIdConnect.IntegrationTests/Common/TestingContext.cs +++ b/SecurityService.OpenIdConnect.IntegrationTests/Common/TestingContext.cs @@ -27,6 +27,8 @@ public class TestingContext public String ConfirmEmailAddressLink { get; set; } public String EmailAddress { get; set; } public String Password { get; set; } + public String MfaAuthenticatorKey { get; set; } + public String MfaRecoveryCode { get; set; } public TestingContext() { @@ -37,4 +39,4 @@ public TestingContext() this.IdentityResources= new List(); } } -} \ No newline at end of file +} diff --git a/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLogin.feature b/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLogin.feature index 8fdc2802..9a490d2b 100644 --- a/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLogin.feature +++ b/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLogin.feature @@ -35,5 +35,72 @@ Scenario: Create User and Login Then I am presented with a login screen When I login with the username 'estateuser@testestate1.co.uk' and the provided password Then I am presented with the privacy screen + +@mfa +Scenario: Enroll MFA and complete an MFA login + Given I create the following users + | Email Address | Phone Number | Given Name | Middle Name | Family Name | Claims | Roles | + | mfauser@testestate1.co.uk | 123456789 | Test | | User 1 | EstateId:1 | Estate | + Then I get an email with a confirm email address link + When I navigate to the confirm email address + Then I am presented with the confirm email address successful screen + And I get a welcome email with my login details + Given I am on the application home page + When I click the 'Privacy' link + Then I am presented with a login screen + When I login with the username 'mfauser@testestate1.co.uk' and the provided password + Then I am presented with the privacy screen + When I open the hosted MFA management page + And I begin MFA enrollment + Then I am shown the MFA authenticator setup key + When I confirm MFA enrollment with the current authenticator code + Then MFA is enabled + When I sign out of SecurityService + Given I am on the application home page + When I click the 'Privacy' link + Then I am presented with a login screen + When I login with the username 'mfauser@testestate1.co.uk' and the provided password + Then I am presented with the MFA verification screen + When I complete MFA with the current authenticator code + Then I am presented with the privacy screen + +@mfa +Scenario: Recovery code is consumed once during MFA login + Given I create the following users + | Email Address | Phone Number | Given Name | Middle Name | Family Name | Claims | Roles | + | recoveryuser@testestate1.co.uk | 123456789 | Test | | User 1 | EstateId:1 | Estate | + Then I get an email with a confirm email address link + When I navigate to the confirm email address + Then I am presented with the confirm email address successful screen + And I get a welcome email with my login details + Given I am on the application home page + When I click the 'Privacy' link + Then I am presented with a login screen + When I login with the username 'recoveryuser@testestate1.co.uk' and the provided password + Then I am presented with the privacy screen + When I open the hosted MFA management page + And I begin MFA enrollment + Then I am shown the MFA authenticator setup key + When I confirm MFA enrollment with the current authenticator code + Then MFA is enabled + When I open the hosted recovery codes page + And I generate new recovery codes + Then a recovery code is displayed once + When I sign out of SecurityService + Given I am on the application home page + When I click the 'Privacy' link + Then I am presented with a login screen + When I login with the username 'recoveryuser@testestate1.co.uk' and the provided password + Then I am presented with the MFA verification screen + When I complete MFA with the current recovery code + Then I am presented with the privacy screen + When I sign out of SecurityService + Given I am on the application home page + When I click the 'Privacy' link + Then I am presented with a login screen + When I login with the username 'recoveryuser@testestate1.co.uk' and the provided password + Then I am presented with the MFA verification screen + When I complete MFA with the current recovery code + Then the MFA verification error is shown diff --git a/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLogin.feature.cs b/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLogin.feature.cs index 19ea044e..f5637ca7 100644 --- a/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLogin.feature.cs +++ b/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLogin.feature.cs @@ -113,52 +113,52 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { #line 4 #line hidden - global::Reqnroll.Table table11 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table1 = new global::Reqnroll.Table(new string[] { "Role Name"}); - table11.AddRow(new string[] { + table1.AddRow(new string[] { "Estate"}); #line 6 - await testRunner.GivenAsync("I create the following roles", ((string)(null)), table11, "Given "); + await testRunner.GivenAsync("I create the following roles", ((string)(null)), table1, "Given "); #line hidden - global::Reqnroll.Table table12 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table2 = new global::Reqnroll.Table(new string[] { "Name", "DisplayName", "Secret", "Scopes", "UserClaims"}); - table12.AddRow(new string[] { + table2.AddRow(new string[] { "transactionProcessor", "Transaction Processor REST", "Secret1", "transactionProcessor", "MerchantId,EstateId,role"}); #line 10 - await testRunner.GivenAsync("I create the following api resources", ((string)(null)), table12, "Given "); + await testRunner.GivenAsync("I create the following api resources", ((string)(null)), table2, "Given "); #line hidden - global::Reqnroll.Table table13 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table3 = new global::Reqnroll.Table(new string[] { "Name", "DisplayName", "Description", "UserClaims"}); - table13.AddRow(new string[] { + table3.AddRow(new string[] { "openid", "Your user identifier", "", "sub"}); - table13.AddRow(new string[] { + table3.AddRow(new string[] { "profile", "User profile", "Your user profile information (first name, last name, etc.)", "name,role,email,given_name,middle_name,family_name,EstateId,MerchantId"}); - table13.AddRow(new string[] { + table3.AddRow(new string[] { "email", "Email", "Email and Email Verified Flags", "email_verified,email"}); #line 14 - await testRunner.GivenAsync("I create the following identity resources", ((string)(null)), table13, "Given "); + await testRunner.GivenAsync("I create the following identity resources", ((string)(null)), table3, "Given "); #line hidden - global::Reqnroll.Table table14 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table4 = new global::Reqnroll.Table(new string[] { "ClientId", "Name", "Secret", @@ -169,7 +169,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "RequireConsent", "AllowOfflineAccess", "ClientUri"}); - table14.AddRow(new string[] { + table4.AddRow(new string[] { "estateUIClient", "Merchant Client", "Secret1", @@ -181,13 +181,13 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "true", "https://[url]:[port]"}); #line 20 - await testRunner.GivenAsync("I create the following clients", ((string)(null)), table14, "Given "); + await testRunner.GivenAsync("I create the following clients", ((string)(null)), table4, "Given "); #line hidden } private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() { - return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("UserLogin/UserLogin.feature.ndjson", 3); + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("UserLogin/UserLogin.feature.ndjson", 5); } [global::NUnit.Framework.TestAttribute()] @@ -215,7 +215,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa #line 4 await this.FeatureBackgroundAsync(); #line hidden - global::Reqnroll.Table table15 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table5 = new global::Reqnroll.Table(new string[] { "Email Address", "Phone Number", "Given Name", @@ -223,7 +223,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "Family Name", "Claims", "Roles"}); - table15.AddRow(new string[] { + table5.AddRow(new string[] { "estateuser@testestate1.co.uk", "123456789", "Test", @@ -232,7 +232,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "EstateId:1", "Estate"}); #line 26 - await testRunner.GivenAsync("I create the following users", ((string)(null)), table15, "Given "); + await testRunner.GivenAsync("I create the following users", ((string)(null)), table5, "Given "); #line hidden #line 29 await testRunner.ThenAsync("I get an email with a confirm email address link", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); @@ -261,6 +261,270 @@ await testRunner.WhenAsync("I login with the username \'estateuser@testestate1.c #line hidden #line 37 await testRunner.ThenAsync("I am presented with the privacy screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::NUnit.Framework.TestAttribute()] + [global::NUnit.Framework.DescriptionAttribute("Enroll MFA and complete an MFA login")] + [global::NUnit.Framework.CategoryAttribute("mfa")] + public async global::System.Threading.Tasks.Task EnrollMFAAndCompleteAnMFALogin() + { + string[] tagsOfScenario = new string[] { + "mfa"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Enroll MFA and complete an MFA login", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 40 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 +await this.FeatureBackgroundAsync(); +#line hidden + global::Reqnroll.Table table6 = new global::Reqnroll.Table(new string[] { + "Email Address", + "Phone Number", + "Given Name", + "Middle Name", + "Family Name", + "Claims", + "Roles"}); + table6.AddRow(new string[] { + "mfauser@testestate1.co.uk", + "123456789", + "Test", + "", + "User 1", + "EstateId:1", + "Estate"}); +#line 41 + await testRunner.GivenAsync("I create the following users", ((string)(null)), table6, "Given "); +#line hidden +#line 44 + await testRunner.ThenAsync("I get an email with a confirm email address link", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 45 + await testRunner.WhenAsync("I navigate to the confirm email address", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 46 + await testRunner.ThenAsync("I am presented with the confirm email address successful screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 47 + await testRunner.AndAsync("I get a welcome email with my login details", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 48 + await testRunner.GivenAsync("I am on the application home page", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 49 + await testRunner.WhenAsync("I click the \'Privacy\' link", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 50 + await testRunner.ThenAsync("I am presented with a login screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 51 + await testRunner.WhenAsync("I login with the username \'mfauser@testestate1.co.uk\' and the provided password", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 52 + await testRunner.ThenAsync("I am presented with the privacy screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 53 + await testRunner.WhenAsync("I open the hosted MFA management page", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 54 + await testRunner.AndAsync("I begin MFA enrollment", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 55 + await testRunner.ThenAsync("I am shown the MFA authenticator setup key", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 56 + await testRunner.WhenAsync("I confirm MFA enrollment with the current authenticator code", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 57 + await testRunner.ThenAsync("MFA is enabled", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 58 + await testRunner.WhenAsync("I sign out of SecurityService", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 59 + await testRunner.GivenAsync("I am on the application home page", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 60 + await testRunner.WhenAsync("I click the \'Privacy\' link", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 61 + await testRunner.ThenAsync("I am presented with a login screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 62 + await testRunner.WhenAsync("I login with the username \'mfauser@testestate1.co.uk\' and the provided password", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 63 + await testRunner.ThenAsync("I am presented with the MFA verification screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 64 + await testRunner.WhenAsync("I complete MFA with the current authenticator code", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 65 + await testRunner.ThenAsync("I am presented with the privacy screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::NUnit.Framework.TestAttribute()] + [global::NUnit.Framework.DescriptionAttribute("Recovery code is consumed once during MFA login")] + [global::NUnit.Framework.CategoryAttribute("mfa")] + public async global::System.Threading.Tasks.Task RecoveryCodeIsConsumedOnceDuringMFALogin() + { + string[] tagsOfScenario = new string[] { + "mfa"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "2"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Recovery code is consumed once during MFA login", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 68 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 +await this.FeatureBackgroundAsync(); +#line hidden + global::Reqnroll.Table table7 = new global::Reqnroll.Table(new string[] { + "Email Address", + "Phone Number", + "Given Name", + "Middle Name", + "Family Name", + "Claims", + "Roles"}); + table7.AddRow(new string[] { + "recoveryuser@testestate1.co.uk", + "123456789", + "Test", + "", + "User 1", + "EstateId:1", + "Estate"}); +#line 69 + await testRunner.GivenAsync("I create the following users", ((string)(null)), table7, "Given "); +#line hidden +#line 72 + await testRunner.ThenAsync("I get an email with a confirm email address link", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 73 + await testRunner.WhenAsync("I navigate to the confirm email address", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 74 + await testRunner.ThenAsync("I am presented with the confirm email address successful screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 75 + await testRunner.AndAsync("I get a welcome email with my login details", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 76 + await testRunner.GivenAsync("I am on the application home page", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 77 + await testRunner.WhenAsync("I click the \'Privacy\' link", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 78 + await testRunner.ThenAsync("I am presented with a login screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 79 + await testRunner.WhenAsync("I login with the username \'recoveryuser@testestate1.co.uk\' and the provided passw" + + "ord", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 80 + await testRunner.ThenAsync("I am presented with the privacy screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 81 + await testRunner.WhenAsync("I open the hosted MFA management page", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 82 + await testRunner.AndAsync("I begin MFA enrollment", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 83 + await testRunner.ThenAsync("I am shown the MFA authenticator setup key", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 84 + await testRunner.WhenAsync("I confirm MFA enrollment with the current authenticator code", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 85 + await testRunner.ThenAsync("MFA is enabled", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 86 + await testRunner.WhenAsync("I open the hosted recovery codes page", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 87 + await testRunner.AndAsync("I generate new recovery codes", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 88 + await testRunner.ThenAsync("a recovery code is displayed once", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 89 + await testRunner.WhenAsync("I sign out of SecurityService", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 90 + await testRunner.GivenAsync("I am on the application home page", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 91 + await testRunner.WhenAsync("I click the \'Privacy\' link", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 92 + await testRunner.ThenAsync("I am presented with a login screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 93 + await testRunner.WhenAsync("I login with the username \'recoveryuser@testestate1.co.uk\' and the provided passw" + + "ord", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 94 + await testRunner.ThenAsync("I am presented with the MFA verification screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 95 + await testRunner.WhenAsync("I complete MFA with the current recovery code", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 96 + await testRunner.ThenAsync("I am presented with the privacy screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 97 + await testRunner.WhenAsync("I sign out of SecurityService", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 98 + await testRunner.GivenAsync("I am on the application home page", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 99 + await testRunner.WhenAsync("I click the \'Privacy\' link", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 100 + await testRunner.ThenAsync("I am presented with a login screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 101 + await testRunner.WhenAsync("I login with the username \'recoveryuser@testestate1.co.uk\' and the provided passw" + + "ord", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 102 + await testRunner.ThenAsync("I am presented with the MFA verification screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 103 + await testRunner.WhenAsync("I complete MFA with the current recovery code", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 104 + await testRunner.ThenAsync("the MFA verification error is shown", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden } await this.ScenarioCleanupAsync(); diff --git a/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLoginSteps.cs b/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLoginSteps.cs index 091db794..d462c00d 100644 --- a/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLoginSteps.cs +++ b/SecurityService.OpenIdConnect.IntegrationTests/UserLogin/UserLoginSteps.cs @@ -9,6 +9,7 @@ namespace SecurityService.IntegrationTests.UserLogin using System.Net; using System.Net.Http; using System.Text; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using HtmlAgilityPack; @@ -133,6 +134,127 @@ await Retry.For(async () => } + [When(@"I open the hosted MFA management page")] + public async Task WhenIOpenTheHostedMfaManagementPage() + { + this.WebDriver.Navigate().GoToUrl($"{this.TestingContext.DockerHelper.securityServiceBaseAddressResolver("")}/Account/ManageMfa"); + await Retry.For(async () => this.WebDriver.Title.ShouldContain("Manage MFA")); + } + + [When(@"I begin MFA enrollment")] + public async Task WhenIBeginMfaEnrollment() + { + await this.WebDriver.ClickButton("Begin enrollment"); + } + + [Then(@"I am shown the MFA authenticator setup key")] + public void ThenIAmShownTheMfaAuthenticatorSetupKey() + { + String body = this.WebDriver.FindElement(By.TagName("body")).Text; + body.ShouldContain("Setup key:"); + this.TestingContext.MfaAuthenticatorKey = body.Split("Setup key:", StringSplitOptions.None)[1].Split('\n')[0].Trim(); + this.TestingContext.MfaAuthenticatorKey.ShouldNotBeNullOrWhiteSpace(); + } + + [When(@"I confirm MFA enrollment with the current authenticator code")] + public async Task WhenIConfirmMfaEnrollmentWithTheCurrentAuthenticatorCode() + { + this.WebDriver.FillIn("Input.Code", GenerateAuthenticatorCode(this.TestingContext.MfaAuthenticatorKey)); + await this.WebDriver.ClickButton("Confirm enrollment"); + } + + [Then(@"MFA is enabled")] + public void ThenMfaIsEnabled() + { + this.WebDriver.FindElement(By.TagName("body")).Text.ShouldContain("MFA has been enabled."); + } + + [When(@"I sign out of SecurityService")] + public async Task WhenISignOutOfSecurityService() + { + this.WebDriver.Navigate().GoToUrl($"{this.TestingContext.DockerHelper.securityServiceBaseAddressResolver("")}/Account/Logout"); + await Retry.For(async () => this.WebDriver.Url.ShouldNotContain("/Account/Logout")); + } + + [When(@"I open the hosted recovery codes page")] + public async Task WhenIOpenTheHostedRecoveryCodesPage() + { + this.WebDriver.Navigate().GoToUrl($"{this.TestingContext.DockerHelper.securityServiceBaseAddressResolver("")}/Account/ManageMfa/RecoveryCodes"); + await Retry.For(async () => this.WebDriver.Title.ShouldContain("Recovery codes")); + } + + [When(@"I generate new recovery codes")] + public async Task WhenIGenerateNewRecoveryCodes() + { + await this.WebDriver.ClickButton("Generate new codes"); + } + + [Then(@"a recovery code is displayed once")] + public void ThenARecoveryCodeIsDisplayedOnce() + { + ReadOnlyCollection codes = this.WebDriver.FindElements(By.CssSelector(".recovery-codes code")); + codes.ShouldNotBeEmpty(); + this.TestingContext.MfaRecoveryCode = codes[0].Text; + this.TestingContext.MfaRecoveryCode.ShouldNotBeNullOrWhiteSpace(); + } + + [When(@"I complete MFA with the current recovery code")] + public async Task WhenICompleteMfaWithTheCurrentRecoveryCode() + { + this.WebDriver.FillIn("Input.Code", this.TestingContext.MfaRecoveryCode); + await this.WebDriver.ClickButton("Verify"); + } + + [Then(@"the MFA verification error is shown")] + public void ThenTheMfaVerificationErrorIsShown() + { + this.WebDriver.FindElement(By.TagName("body")).Text.ShouldContain("authenticator or recovery code was invalid"); + } + + [Then(@"I am presented with the MFA verification screen")] + public async Task ThenIAmPresentedWithTheMfaVerificationScreen() + { + await Retry.For(async () => this.WebDriver.FindElement(By.TagName("body")).Text.ShouldContain("MFA verification")); + } + + [When(@"I complete MFA with the current authenticator code")] + public async Task WhenICompleteMfaWithTheCurrentAuthenticatorCode() + { + this.WebDriver.FillIn("Input.Code", GenerateAuthenticatorCode(this.TestingContext.MfaAuthenticatorKey)); + await this.WebDriver.ClickButton("Verify"); + } + + private static String GenerateAuthenticatorCode(String key) + { + const String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + Int32 bits = 0; + Int32 bitCount = 0; + List keyBytes = new List(); + foreach (Char character in key.TrimEnd('=').ToUpperInvariant()) + { + bits = (bits << 5) | alphabet.IndexOf(character); + bitCount += 5; + if (bitCount >= 8) + { + bitCount -= 8; + keyBytes.Add((Byte)(bits >> bitCount)); + bits &= (1 << bitCount) - 1; + } + } + + Int64 counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30; + Byte[] counterBytes = BitConverter.GetBytes(counter); + if (BitConverter.IsLittleEndian) Array.Reverse(counterBytes); + using HMACSHA1 hmac = new HMACSHA1(keyBytes.ToArray()); + Byte[] hash = hmac.ComputeHash(counterBytes); + Int32 offset = hash[^1] & 0x0f; + Int32 binaryCode = ((hash[offset] & 0x7f) << 24) | + (hash[offset + 1] << 16) | + (hash[offset + 2] << 8) | + hash[offset + 3]; + return (binaryCode % 1_000_000).ToString("D6"); + } + [Then(@"I get an email with a confirm email address link")] public async Task ThenIGetAnEmailWithAConfirmEmailAddressLink() { diff --git a/SecurityService.UnitTests/Authorization/MfaPolicyServiceTests.cs b/SecurityService.UnitTests/Authorization/MfaPolicyServiceTests.cs new file mode 100644 index 00000000..7f001e5f --- /dev/null +++ b/SecurityService.UnitTests/Authorization/MfaPolicyServiceTests.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.Database.DbContexts; +using SecurityService.Database.Entities; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; + +namespace SecurityService.UnitTests.Authorization; + +public sealed class MfaPolicyServiceTests +{ + [Fact] + public async Task IsMfaRequired_returns_false_when_user_and_roles_have_no_policy() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + + var service = CreateService(scope); + + (await service.IsMfaRequiredAsync(user, CancellationToken.None)).ShouldBeFalse(); + } + + [Fact] + public async Task IsMfaRequired_returns_true_for_direct_user_policy() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + + (await service.RequireUserAsync(user.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + + (await service.IsMfaRequiredAsync(user, CancellationToken.None)).ShouldBeTrue(); + } + + [Fact] + public async Task IsMfaRequired_returns_true_for_any_role_policy() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + (await roleManager.CreateAsync(new IdentityRole("Estate"))).Succeeded.ShouldBeTrue(); + (await userManager.AddToRoleAsync(user, "Estate")).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + + var role = await roleManager.FindByNameAsync("Estate"); + (await service.RequireRoleAsync(role!.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + + (await service.IsMfaRequiredAsync(user, CancellationToken.None)).ShouldBeTrue(); + } + + [Fact] + public async Task Require_and_remove_operations_are_idempotent() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + + (await service.RequireUserAsync(user.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + (await service.RequireUserAsync(user.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + (await service.RemoveUserAsync(user.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + (await service.RemoveUserAsync(user.Id, CancellationToken.None)).IsSuccess.ShouldBeTrue(); + + (await service.IsMfaRequiredAsync(user, CancellationToken.None)).ShouldBeFalse(); + } + + private static MfaPolicyService CreateService(IServiceScope scope) + { + return new MfaPolicyService( + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/SecurityService.UnitTests/Database/MfaPersistenceTests.cs b/SecurityService.UnitTests/Database/MfaPersistenceTests.cs new file mode 100644 index 00000000..ab7290b0 --- /dev/null +++ b/SecurityService.UnitTests/Database/MfaPersistenceTests.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using SecurityService.Database.DbContexts; +using SecurityService.Database.Entities; +using Shouldly; + +namespace SecurityService.UnitTests.Database; + +public sealed class MfaPersistenceTests +{ + [Fact] + public void Model_contains_mfa_policy_and_recovery_code_sets_with_expected_indexes() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + using var context = new SecurityServiceDbContext(options); + + context.MfaPolicies.ShouldNotBeNull(); + context.RecoveryCodes.ShouldNotBeNull(); + + var policyIndexes = context.Model.FindEntityType(typeof(MfaPolicy))!.GetIndexes(); + policyIndexes.Any(index => index.Properties.Select(property => property.Name).SequenceEqual(new[] { nameof(MfaPolicy.TargetType), nameof(MfaPolicy.TargetId) }) && index.IsUnique).ShouldBeTrue(); + + var recoveryIndexes = context.Model.FindEntityType(typeof(RecoveryCode))!.GetIndexes(); + recoveryIndexes.Any(index => index.Properties.Select(property => property.Name).SequenceEqual(new[] { nameof(RecoveryCode.UserId), nameof(RecoveryCode.ConsumedUtc) })).ShouldBeTrue(); + } + + [Fact] + public async Task Recovery_code_records_persist_hash_and_consumed_timestamp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var userId = Guid.NewGuid().ToString(); + var consumedUtc = DateTime.UtcNow; + + await using (var context = new SecurityServiceDbContext(options)) + { + context.RecoveryCodes.Add(new RecoveryCode + { + Id = Guid.NewGuid(), + UserId = userId, + CodeHash = "hash", + CreatedUtc = DateTime.UtcNow, + ConsumedUtc = consumedUtc + }); + await context.SaveChangesAsync(); + } + + await using (var context = new SecurityServiceDbContext(options)) + { + var code = await context.RecoveryCodes.SingleAsync(); + code.UserId.ShouldBe(userId); + code.CodeHash.ShouldBe("hash"); + code.ConsumedUtc.ShouldBe(consumedUtc); + } + } +} diff --git a/SecurityService.UnitTests/Mfa/MfaAccountServiceTests.cs b/SecurityService.UnitTests/Mfa/MfaAccountServiceTests.cs new file mode 100644 index 00000000..9cc50b5d --- /dev/null +++ b/SecurityService.UnitTests/Mfa/MfaAccountServiceTests.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System.Buffers.Binary; +using System.Security.Cryptography; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.Database.DbContexts; +using SecurityService.Database.Entities; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; + +namespace SecurityService.UnitTests.Mfa; + +public sealed class MfaAccountServiceTests +{ + [Fact] + public async Task Recovery_codes_are_hashed_returned_once_and_consumed_once() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user, "Pa55word!")).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + + var codes = await service.GenerateRecoveryCodesAsync(user, 2, CancellationToken.None); + + codes.Count.ShouldBe(2); + var stored = await scope.ServiceProvider.GetRequiredService().RecoveryCodes.ToListAsync(); + stored.Count.ShouldBe(2); + stored.ShouldAllBe(code => codes.Contains(code.CodeHash) == false); + (await service.RedeemRecoveryCodeAsync(user, codes[0], CancellationToken.None)).ShouldBeTrue(); + (await service.RedeemRecoveryCodeAsync(user, codes[0], CancellationToken.None)).ShouldBeFalse(); + + var consumed = stored.Single(code => code.ConsumedUtc is not null); + consumed.ConsumedUtc.ShouldNotBeNull(); + } + + [Fact] + public async Task Confirm_enrollment_enables_identity_two_factor_only_after_valid_code() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user", Email = "mfa@example.com" }; + (await userManager.CreateAsync(user, "Pa55word!")).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + + var enrollment = await service.BeginEnrollmentAsync(user, "SecurityService", CancellationToken.None); + (await userManager.GetTwoFactorEnabledAsync(user)).ShouldBeFalse(); + var token = GenerateAuthenticatorCode(enrollment.AuthenticatorKey); + + (await service.ConfirmEnrollmentAsync(user, "000000", CancellationToken.None)).ShouldBeFalse(); + (await service.ConfirmEnrollmentAsync(user, token, CancellationToken.None)).ShouldBeTrue(); + (await userManager.GetTwoFactorEnabledAsync(user)).ShouldBeTrue(); + enrollment.AuthenticatorKey.ShouldNotBeNullOrWhiteSpace(); + enrollment.OtpauthUri.ShouldStartWith("otpauth://totp/"); + } + + [Fact] + public async Task Removing_authenticator_requires_reauthentication_and_revokes_recovery_codes() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user", Email = "mfa@example.com" }; + (await userManager.CreateAsync(user, "Pa55word!")).Succeeded.ShouldBeTrue(); + var service = CreateService(scope); + var enrollment = await service.BeginEnrollmentAsync(user, "SecurityService", CancellationToken.None); + var token = GenerateAuthenticatorCode(enrollment.AuthenticatorKey); + (await service.ConfirmEnrollmentAsync(user, token, CancellationToken.None)).ShouldBeTrue(); + await service.GenerateRecoveryCodesAsync(user, 2, CancellationToken.None); + + (await service.RemoveAuthenticatorAsync(user, "wrong", "000000", CancellationToken.None)).ShouldBeFalse(); + (await service.RemoveAuthenticatorAsync(user, "Pa55word!", null, CancellationToken.None)).ShouldBeTrue(); + + (await userManager.GetTwoFactorEnabledAsync(user)).ShouldBeFalse(); + (await scope.ServiceProvider.GetRequiredService().RecoveryCodes.CountAsync()).ShouldBe(0); + enrollment.AuthenticatorKey.ShouldNotBeNullOrWhiteSpace(); + } + + private static MfaAccountService CreateService(IServiceScope scope) + { + return new MfaAccountService( + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService(), + new RecoveryCodeGenerator()); + } + + private static string GenerateAuthenticatorCode(string key) + { + const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + var bits = new List(); + var buffer = 0; + var bitCount = 0; + foreach (var character in key) + { + buffer = (buffer << 5) | alphabet.IndexOf(char.ToUpperInvariant(character)); + bitCount += 5; + while (bitCount >= 8) + { + bitCount -= 8; + bits.Add((byte)(buffer >> bitCount)); + } + } + + var counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30; + Span counterBytes = stackalloc byte[8]; + BinaryPrimitives.WriteInt64BigEndian(counterBytes, counter); + using var hmac = new HMACSHA1(bits.ToArray()); + var hash = hmac.ComputeHash(counterBytes.ToArray()); + var offset = hash[^1] & 0x0f; + var value = ((hash[offset] & 0x7f) << 24) | + (hash[offset + 1] << 16) | + (hash[offset + 2] << 8) | + hash[offset + 3]; + return (value % 1_000_000).ToString("D6"); + } +} diff --git a/SecurityService.UnitTests/Mfa/MfaSignInTransactionProtectorTests.cs b/SecurityService.UnitTests/Mfa/MfaSignInTransactionProtectorTests.cs new file mode 100644 index 00000000..29270d13 --- /dev/null +++ b/SecurityService.UnitTests/Mfa/MfaSignInTransactionProtectorTests.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.DataProtection; +using SecurityService.BusinessLogic.Mfa; +using Shouldly; + +namespace SecurityService.UnitTests.Mfa; + +public sealed class MfaSignInTransactionProtectorTests +{ + [Fact] + public void Protect_RoundTripsTransactionWithoutExposingItsPayload() + { + var protector = new MfaSignInTransactionProtector(new EphemeralDataProtectionProvider()); + + var token = protector.Protect("user-1", "/connect/authorize?client_id=client", rememberLogin: true); + + protector.TryUnprotect(token, out var transaction).ShouldBeTrue(); + transaction.ShouldNotBeNull(); + transaction.UserId.ShouldBe("user-1"); + transaction.ReturnUrl.ShouldBe("/connect/authorize?client_id=client"); + transaction.RememberLogin.ShouldBeTrue(); + token.ShouldNotContain("user-1"); + } + + [Fact] + public void TryUnprotect_RejectsTamperedTransaction() + { + var protector = new MfaSignInTransactionProtector(new EphemeralDataProtectionProvider()); + var token = protector.Protect("user-1", "/", rememberLogin: false); + + protector.TryUnprotect(token + "tampered", out _).ShouldBeFalse(); + } +} diff --git a/SecurityService.UnitTests/Mfa/RecoveryCodeGeneratorTests.cs b/SecurityService.UnitTests/Mfa/RecoveryCodeGeneratorTests.cs new file mode 100644 index 00000000..3c8933a9 --- /dev/null +++ b/SecurityService.UnitTests/Mfa/RecoveryCodeGeneratorTests.cs @@ -0,0 +1,19 @@ +using SecurityService.BusinessLogic.Mfa; +using Shouldly; + +namespace SecurityService.UnitTests.Mfa; + +public sealed class RecoveryCodeGeneratorTests +{ + [Fact] + public void Generate_returns_unique_codes_with_expected_shape() + { + var generator = new RecoveryCodeGenerator(); + + var codes = generator.Generate(10); + + codes.Count.ShouldBe(10); + codes.Distinct(StringComparer.Ordinal).Count().ShouldBe(10); + codes.ShouldAllBe(code => code.Length == 16 && code.All(char.IsLetterOrDigit)); + } +} diff --git a/SecurityService.UnitTests/Oidc/OAuthGrantPolicyTests.cs b/SecurityService.UnitTests/Oidc/OAuthGrantPolicyTests.cs index 2ead67b9..b57b9633 100644 --- a/SecurityService.UnitTests/Oidc/OAuthGrantPolicyTests.cs +++ b/SecurityService.UnitTests/Oidc/OAuthGrantPolicyTests.cs @@ -54,6 +54,36 @@ public void LegacyGrants_AreRejectedUnlessClientIsConfigured() OAuthGrantPolicy.IsGrantAllowed(GrantTypes.Password, "other-client", options).ShouldBeFalse(); } + [Fact] + public void HybridFlow_IsRejectedWhenFeatureIsDisabled() + { + var options = new OAuthOptions + { + EnableHybridFlow = false, + LegacyGrantTypeClients = new(StringComparer.OrdinalIgnoreCase) + { + ["hybrid"] = ["browser-client"] + } + }; + + OAuthGrantPolicy.IsGrantAllowed("hybrid", "browser-client", options).ShouldBeFalse(); + } + + [Fact] + public void HybridFlow_IsAllowedForConfiguredClientWhenFeatureIsEnabled() + { + var options = new OAuthOptions + { + EnableHybridFlow = true, + LegacyGrantTypeClients = new(StringComparer.OrdinalIgnoreCase) + { + ["hybrid"] = ["browser-client"] + } + }; + + OAuthGrantPolicy.IsGrantAllowed("hybrid", "browser-client", options).ShouldBeTrue(); + } + [Fact] public void UnknownGrant_IsRejected() { diff --git a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs index 2a03dfe1..974a8b77 100644 --- a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs +++ b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs @@ -2,11 +2,13 @@ using MediatR; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.DataProtection; using Imposter.Abstractions; using OpenIddict.Validation.AspNetCore; using SecurityService.BusinessLogic.Oidc; +using SecurityService.BusinessLogic.Mfa; using SecurityService.Database; using SecurityService.Database.DbContexts; using SecurityService.Database.Entities; @@ -63,7 +65,8 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload() authManager.Instance(), scopeManager.Instance(), dbContext, - new ConsentTransactionProtector(new EphemeralDataProtectionProvider().CreateProtector("consent"))); + new ConsentTransactionProtector(new EphemeralDataProtectionProvider().CreateProtector("consent")), + new MfaPolicyService(userManager.Instance(), scope.ServiceProvider.GetRequiredService>(), dbContext)); var result = await handler.Handle(new OidcCommands.UserInfoCommand(context), CancellationToken.None); diff --git a/SecurityService.UnitTests/Pages/MfaLoginPageModelTests.cs b/SecurityService.UnitTests/Pages/MfaLoginPageModelTests.cs new file mode 100644 index 00000000..3ba5ea9f --- /dev/null +++ b/SecurityService.UnitTests/Pages/MfaLoginPageModelTests.cs @@ -0,0 +1,85 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Imposter.Abstractions; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.BusinessLogic.Requests; +using SecurityService.Models; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; +using SimpleResults; + +namespace SecurityService.UnitTests.Pages; + +public sealed class MfaLoginPageModelTests +{ + [Fact] + public async Task Login_WhenMfaIsRequired_RedirectsToChallengeWithProtectedTransaction() + { + var mediator = new IMediatorImposter(); + mediator.Send(Arg>> >.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(new List())); + mediator.Send(Arg>>.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(new LoginRequiresMfa("protected-transaction"))); + + var model = new SecurityService.Pages.Account.Login.IndexModel(mediator.Instance()) + { + Input = new SecurityService.Pages.Account.Login.IndexModel.InputModel + { + Username = "alice", + Password = "password", + ReturnUrl = "/connect/authorize?client_id=client" + } + }; + + var result = await model.OnPostAsync(); + + var redirect = result.ShouldBeOfType(); + redirect.PageName.ShouldBe("/Account/LoginWithMfa/Index"); + redirect.RouteValues!["transaction"].ShouldBe("protected-transaction"); + } + + [Fact] + public async Task Challenge_WhenCodeIsInvalid_RedisplaysPageWithError() + { + var mediator = new IMediatorImposter(); + mediator.Send(Arg>>.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(new LoginRejected("The authenticator or recovery code was invalid."))); + + var model = new SecurityService.Pages.Account.LoginWithMfa.IndexModel(mediator.Instance()) + { + Input = new SecurityService.Pages.Account.LoginWithMfa.IndexModel.InputModel + { + Transaction = "protected-transaction", + Code = "000000" + } + }; + + var result = await model.OnPostAsync(); + + result.ShouldBeOfType(); + model.ModelState[string.Empty]!.Errors.Single().ErrorMessage.ShouldBe("The authenticator or recovery code was invalid."); + } + + [Fact] + public async Task Challenge_WhenCodeIsValid_RedirectsToProtectedReturnUrl() + { + var mediator = new IMediatorImposter(); + mediator.Send(Arg>>.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(new LoginCompleted("/connect/authorize?client_id=client"))); + + var model = new SecurityService.Pages.Account.LoginWithMfa.IndexModel(mediator.Instance()) + { + Input = new SecurityService.Pages.Account.LoginWithMfa.IndexModel.InputModel + { + Transaction = "protected-transaction", + Code = "123456" + } + }; + + var result = await model.OnPostAsync(); + + var redirect = result.ShouldBeOfType(); + redirect.Url.ShouldBe("/connect/authorize?client_id=client"); + } +} diff --git a/SecurityService.UnitTests/RequestHandlers/MfaPolicyRequestHandlerTests.cs b/SecurityService.UnitTests/RequestHandlers/MfaPolicyRequestHandlerTests.cs new file mode 100644 index 00000000..a70603e3 --- /dev/null +++ b/SecurityService.UnitTests/RequestHandlers/MfaPolicyRequestHandlerTests.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.BusinessLogic.RequestHandlers; +using SecurityService.BusinessLogic.Requests; +using SecurityService.Database.Entities; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; + +namespace SecurityService.UnitTests.RequestHandlers; + +public sealed class MfaPolicyRequestHandlerTests +{ + [Fact] + public async Task User_policy_commands_are_idempotent_and_remove_the_policy() + { + using var provider = TestServiceProviderFactory.Create(Guid.NewGuid().ToString()); + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new ApplicationUser { UserName = "mfa-user" }; + (await userManager.CreateAsync(user)).Succeeded.ShouldBeTrue(); + + var handler = new MfaPolicyRequestHandler(CreateService(scope)); + + (await handler.Handle(new SecurityServiceCommands.RequireUserMfaCommand(user.Id), CancellationToken.None)).IsSuccess.ShouldBeTrue(); + (await handler.Handle(new SecurityServiceCommands.RequireUserMfaCommand(user.Id), CancellationToken.None)).IsSuccess.ShouldBeTrue(); + (await handler.Handle(new SecurityServiceCommands.RemoveUserMfaCommand(user.Id), CancellationToken.None)).IsSuccess.ShouldBeTrue(); + + var service = CreateService(scope); + (await service.IsMfaRequiredAsync(user, CancellationToken.None)).ShouldBeFalse(); + } + + private static MfaPolicyService CreateService(IServiceScope scope) + { + return new MfaPolicyService( + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/SecurityService/Configuration/KestrelCertificateLoader.cs b/SecurityService/Configuration/KestrelCertificateLoader.cs index ae73c788..2a41689b 100644 --- a/SecurityService/Configuration/KestrelCertificateLoader.cs +++ b/SecurityService/Configuration/KestrelCertificateLoader.cs @@ -43,7 +43,10 @@ private static X509Certificate2 LoadCertificateFromPath(string certificatePath, throw new FileNotFoundException($"The configured Kestrel certificate '{certificatePath}' was not found.", certificatePath); } - return X509CertificateLoader.LoadPkcs12FromFile(certificatePath, password); + return X509CertificateLoader.LoadPkcs12FromFile( + certificatePath, + password, + X509KeyStorageFlags.EphemeralKeySet); } private static string ResolveCertificatePath(string certificatePath, string contentRootPath) diff --git a/SecurityService/Endpoints/ManagementEndpoints.cs b/SecurityService/Endpoints/ManagementEndpoints.cs index 3a011538..81f995fd 100644 --- a/SecurityService/Endpoints/ManagementEndpoints.cs +++ b/SecurityService/Endpoints/ManagementEndpoints.cs @@ -32,5 +32,9 @@ public static void MapManagementEndpoints(this IEndpointRouteBuilder endpoints) management.MapPost("/users", Handlers.UserHandler.CreateUser).WithName("CreateUser"); management.MapGet("/users/{userId}", Handlers.UserHandler.GetUser).WithName("GetUser"); management.MapGet("/users", Handlers.UserHandler.GetUsers).WithName("GetUsers"); + management.MapPut("/users/{userId}/mfa-policy", Handlers.UserHandler.RequireMfa).WithName("RequireUserMfa"); + management.MapDelete("/users/{userId}/mfa-policy", Handlers.UserHandler.RemoveMfa).WithName("RemoveUserMfa"); + management.MapPut("/roles/{roleId}/mfa-policy", Handlers.RoleHandler.RequireMfa).WithName("RequireRoleMfa"); + management.MapDelete("/roles/{roleId}/mfa-policy", Handlers.RoleHandler.RemoveMfa).WithName("RemoveRoleMfa"); } } diff --git a/SecurityService/Handlers/RoleHandler.cs b/SecurityService/Handlers/RoleHandler.cs index 11da65f9..5b15f1ab 100644 --- a/SecurityService/Handlers/RoleHandler.cs +++ b/SecurityService/Handlers/RoleHandler.cs @@ -10,6 +10,22 @@ namespace SecurityService.Handlers; public static class RoleHandler { + public static async Task RequireMfa(IMediator mediator, + string roleId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SecurityServiceCommands.RequireRoleMfaCommand(roleId), cancellationToken); + return ResponseFactory.FromResult(result); + } + + public static async Task RemoveMfa(IMediator mediator, + string roleId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SecurityServiceCommands.RemoveRoleMfaCommand(roleId), cancellationToken); + return ResponseFactory.FromResult(result); + } + public static async Task CreateRole(IMediator mediator, CreateRoleRequest request, CancellationToken cancellationToken) { SecurityServiceCommands.CreateRoleCommand command = new(request.RoleName); diff --git a/SecurityService/Handlers/UserHandler.cs b/SecurityService/Handlers/UserHandler.cs index 357ee2b3..9d032735 100644 --- a/SecurityService/Handlers/UserHandler.cs +++ b/SecurityService/Handlers/UserHandler.cs @@ -10,6 +10,22 @@ namespace SecurityService.Handlers; public static class UserHandler { + public static async Task RequireMfa(IMediator mediator, + string userId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SecurityServiceCommands.RequireUserMfaCommand(userId), cancellationToken); + return ResponseFactory.FromResult(result); + } + + public static async Task RemoveMfa(IMediator mediator, + string userId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new SecurityServiceCommands.RemoveUserMfaCommand(userId), cancellationToken); + return ResponseFactory.FromResult(result); + } + public static async Task CreateUser(IMediator mediator, CreateUserRequest request, CancellationToken cancellationToken) { SecurityServiceCommands.CreateUserCommand command = new(request.GivenName, request.MiddleName, request.FamilyName, request.UserName, request.Password, request.EmailAddress, request.PhoneNumber, request.Claims, request.Roles); diff --git a/SecurityService/HostedServices/DatabaseInitializer.cs b/SecurityService/HostedServices/DatabaseInitializer.cs index 5fd50fd3..67873165 100644 --- a/SecurityService/HostedServices/DatabaseInitializer.cs +++ b/SecurityService/HostedServices/DatabaseInitializer.cs @@ -84,8 +84,16 @@ private async Task SynchronizeApplicationPermissionsAsync(IServiceProvider servi OpenIddictApplicationDescriptor descriptor = new(); await applicationManager.PopulateAsync(descriptor, application, cancellationToken); + IReadOnlyCollection allowedGrantTypes = JsonListSerializer.Deserialize(client.AllowedGrantTypesJson); + if (this.Options.OAuth.EnableHybridFlow == false) + { + allowedGrantTypes = allowedGrantTypes + .Where(grantType => string.Equals(grantType, "hybrid", StringComparison.OrdinalIgnoreCase) == false) + .ToArray(); + } + OAuthGrantPolicyResult policy = OAuthGrantPolicy.CreatePermissions( - JsonListSerializer.Deserialize(client.AllowedGrantTypesJson), + allowedGrantTypes, client.ClientId, this.Options.OAuth); diff --git a/SecurityService/Pages/Account/EnrollMfa/Index.cshtml b/SecurityService/Pages/Account/EnrollMfa/Index.cshtml new file mode 100644 index 00000000..9c21f403 --- /dev/null +++ b/SecurityService/Pages/Account/EnrollMfa/Index.cshtml @@ -0,0 +1,33 @@ +@page +@model SecurityService.Pages.Account.EnrollMfa.IndexModel +@{ + ViewData["Title"] = "MFA enrollment"; +} + +
+
+

MFA enrollment

+

Your account must be protected with an authenticator app before you can continue.

+
+ + + + @if (Model.Enrollment is not null) + { +
+
+

Scan the QR code with your authenticator app, then enter the six-digit code it displays.

+

Authenticator setup QR code

+

Setup key: @Model.Enrollment.AuthenticatorKey

+
+ +
+ + +
+ +
+
+
+ } +
diff --git a/SecurityService/Pages/Account/EnrollMfa/Index.cshtml.cs b/SecurityService/Pages/Account/EnrollMfa/Index.cshtml.cs new file mode 100644 index 00000000..131d5500 --- /dev/null +++ b/SecurityService/Pages/Account/EnrollMfa/Index.cshtml.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.Database.Entities; + +namespace SecurityService.Pages.Account.EnrollMfa; + +[AllowAnonymous] +public sealed class IndexModel : PageModel +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly MfaAccountService _mfaAccountService; + private readonly MfaEnrollmentTransactionProtector _transactionProtector; + + public IndexModel( + UserManager userManager, + SignInManager signInManager, + MfaAccountService mfaAccountService, + MfaEnrollmentTransactionProtector transactionProtector) + { + _userManager = userManager; + _signInManager = signInManager; + _mfaAccountService = mfaAccountService; + _transactionProtector = transactionProtector; + } + + [BindProperty] + public InputModel Input { get; set; } = new(); + + public MfaEnrollment? Enrollment { get; private set; } + + public string QrCodeDataUri { get; private set; } = string.Empty; + + public async Task OnGetAsync(string transaction, CancellationToken cancellationToken) + { + this.Input.Transaction = transaction; + return await this.LoadEnrollmentAsync(cancellationToken); + } + + public async Task OnPostAsync(CancellationToken cancellationToken) + { + if (_transactionProtector.TryUnprotect(this.Input.Transaction, out var transaction) == false || transaction is null) + { + ModelState.AddModelError(string.Empty, "The MFA enrollment request has expired. Start signing in again."); + return Page(); + } + + var user = await _userManager.FindByIdAsync(transaction.UserId); + if (user is null) + { + ModelState.AddModelError(string.Empty, "The MFA enrollment request is no longer valid."); + return Page(); + } + + var confirmed = await _mfaAccountService.ConfirmEnrollmentAsync(user, this.Input.Code, cancellationToken); + if (confirmed == false) + { + ModelState.AddModelError(string.Empty, "The authenticator code was invalid."); + await this.LoadEnrollmentAsync(cancellationToken); + return Page(); + } + + await _signInManager.SignInAsync(user, isPersistent: false); + return this.LocalRedirect(string.IsNullOrWhiteSpace(transaction.ReturnUrl) ? "/" : transaction.ReturnUrl); + } + + private async Task LoadEnrollmentAsync(CancellationToken cancellationToken) + { + if (_transactionProtector.TryUnprotect(this.Input.Transaction, out var transaction) == false || transaction is null) + { + ModelState.AddModelError(string.Empty, "The MFA enrollment request has expired. Start signing in again."); + return Page(); + } + + var user = await _userManager.FindByIdAsync(transaction.UserId); + if (user is null) + { + ModelState.AddModelError(string.Empty, "The MFA enrollment request is no longer valid."); + return Page(); + } + + this.Enrollment = await _mfaAccountService.GetEnrollmentAsync(user, "SecurityService", cancellationToken); + this.QrCodeDataUri = CreateQrCodeDataUri(this.Enrollment.OtpauthUri); + return Page(); + } + + private static string CreateQrCodeDataUri(string value) + { + using var generator = new QRCoder.QRCodeGenerator(); + using var qrCodeData = generator.CreateQrCode(value, QRCoder.QRCodeGenerator.ECCLevel.Q); + var png = new QRCoder.PngByteQRCode(qrCodeData).GetGraphic(12); + return $"data:image/png;base64,{Convert.ToBase64String(png)}"; + } + + public sealed class InputModel + { + public string Transaction { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; + } +} diff --git a/SecurityService/Pages/Account/Login/Index.cshtml.cs b/SecurityService/Pages/Account/Login/Index.cshtml.cs index 1383fdb3..e3919143 100644 --- a/SecurityService/Pages/Account/Login/Index.cshtml.cs +++ b/SecurityService/Pages/Account/Login/Index.cshtml.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; +using SecurityService.BusinessLogic.Mfa; using SecurityService.BusinessLogic.Requests; namespace SecurityService.Pages.Account.Login; @@ -52,13 +53,24 @@ public async Task OnPostAsync() }); } - var result = await this._mediator.Send(new SecurityServiceCommands.LoginCommand(this.Input.Username, this.Input.Password, this.Input.RememberLogin)); - if (result.IsSuccess) + var result = await this._mediator.Send(new SecurityServiceCommands.BeginLoginCommand(this.Input.Username, this.Input.Password, this.Input.RememberLogin, this.Input.ReturnUrl)); + if (result.IsSuccess && result.Data is LoginCompleted completed) { - return this.LocalRedirect(string.IsNullOrWhiteSpace(this.Input.ReturnUrl) ? "/" : this.Input.ReturnUrl); + return this.LocalRedirect(string.IsNullOrWhiteSpace(completed.ReturnUrl) ? "/" : completed.ReturnUrl); } - this.ModelState.AddModelError(string.Empty, result.Errors.FirstOrDefault() ?? result.Message ?? "Invalid username or password."); + if (result.IsSuccess && result.Data is LoginRequiresMfa requiresMfa) + { + return this.RedirectToPage("/Account/LoginWithMfa/Index", new { transaction = requiresMfa.Transaction }); + } + + if (result.IsSuccess && result.Data is LoginRequiresMfaEnrollment requiresMfaEnrollment) + { + return this.RedirectToPage("/Account/EnrollMfa/Index", new { transaction = requiresMfaEnrollment.Transaction }); + } + + var rejection = result.Data as LoginRejected; + this.ModelState.AddModelError(string.Empty, rejection?.Message ?? result.Errors.FirstOrDefault() ?? result.Message ?? "Invalid username or password."); return this.Page(); } diff --git a/SecurityService/Pages/Account/LoginWithMfa/Index.cshtml b/SecurityService/Pages/Account/LoginWithMfa/Index.cshtml new file mode 100644 index 00000000..8a107cfe --- /dev/null +++ b/SecurityService/Pages/Account/LoginWithMfa/Index.cshtml @@ -0,0 +1,27 @@ +@page +@model SecurityService.Pages.Account.LoginWithMfa.IndexModel +@{ + ViewData["Title"] = "MFA verification"; +} + +
+
+

MFA verification

+

Enter the code from your authenticator app or use a recovery code.

+
+ + + +
+
+
+ +
+ + +
+ +
+
+
+
diff --git a/SecurityService/Pages/Account/LoginWithMfa/Index.cshtml.cs b/SecurityService/Pages/Account/LoginWithMfa/Index.cshtml.cs new file mode 100644 index 00000000..6499e9af --- /dev/null +++ b/SecurityService/Pages/Account/LoginWithMfa/Index.cshtml.cs @@ -0,0 +1,45 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.BusinessLogic.Requests; + +namespace SecurityService.Pages.Account.LoginWithMfa; + +public sealed class IndexModel : PageModel +{ + private readonly IMediator _mediator; + + public IndexModel(IMediator mediator) + { + _mediator = mediator; + } + + [BindProperty] + public InputModel Input { get; set; } = new(); + + public IActionResult OnGet(string transaction) + { + Input.Transaction = transaction; + return Page(); + } + + public async Task OnPostAsync() + { + var result = await _mediator.Send(new SecurityServiceCommands.CompleteMfaLoginCommand(Input.Transaction, Input.Code)); + if (result.IsSuccess && result.Data is LoginCompleted completed) + { + return LocalRedirect(string.IsNullOrWhiteSpace(completed.ReturnUrl) ? "/" : completed.ReturnUrl); + } + + var rejection = result.Data as LoginRejected; + ModelState.AddModelError(string.Empty, rejection?.Message ?? "The authenticator or recovery code was invalid."); + return Page(); + } + + public sealed class InputModel + { + public string Transaction { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + } +} diff --git a/SecurityService/Pages/Account/ManageMfa/Index.cshtml b/SecurityService/Pages/Account/ManageMfa/Index.cshtml new file mode 100644 index 00000000..cf70a891 --- /dev/null +++ b/SecurityService/Pages/Account/ManageMfa/Index.cshtml @@ -0,0 +1,64 @@ +@page +@model SecurityService.Pages.Account.ManageMfa.IndexModel +@{ + ViewData["Title"] = "Manage MFA"; +} + +
+
+

Manage MFA

+

Protect your account with an authenticator app.

+
+ + @if (string.IsNullOrWhiteSpace(Model.StatusMessage) == false) + { +
@Model.StatusMessage
+ } + + + +
+
+ @if (Model.IsMfaEnabled) + { +

MFA is enabled

+

Use your authenticator app or a recovery code when you sign in.

+
+
+ + +
+
+ + +
+ +
+

Manage recovery codes

+ } + else if (Model.Enrollment is null) + { +

MFA is not enabled

+

Set up an authenticator app to add a second factor to your account.

+
+ +
+ } + else + { +

Scan the QR code

+

Add this account to your authenticator app, then enter the six-digit code it shows.

+

Authenticator setup QR code

+

Setup key: @Model.Enrollment.AuthenticatorKey

+

Authenticator URI: @Model.Enrollment.OtpauthUri

+
+
+ + +
+ +
+ } +
+
+
diff --git a/SecurityService/Pages/Account/ManageMfa/Index.cshtml.cs b/SecurityService/Pages/Account/ManageMfa/Index.cshtml.cs new file mode 100644 index 00000000..021d877f --- /dev/null +++ b/SecurityService/Pages/Account/ManageMfa/Index.cshtml.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using QRCoder; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.Database.Entities; + +namespace SecurityService.Pages.Account.ManageMfa; + +[Authorize] +public sealed class IndexModel : PageModel +{ + private readonly UserManager _userManager; + private readonly MfaAccountService _mfaAccountService; + + public IndexModel(UserManager userManager, MfaAccountService mfaAccountService) + { + _userManager = userManager; + _mfaAccountService = mfaAccountService; + } + + [BindProperty] + public InputModel Input { get; set; } = new(); + + public MfaEnrollment? Enrollment { get; private set; } + + public bool IsMfaEnabled { get; private set; } + + public string QrCodeDataUri { get; private set; } = string.Empty; + + public string StatusMessage { get; private set; } = string.Empty; + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + await this.LoadStateAsync(cancellationToken); + return Page(); + } + + public async Task OnPostBeginEnrollmentAsync(CancellationToken cancellationToken) + { + var user = await this.GetUserAsync(); + if (user is null) + { + return Challenge(); + } + + this.Enrollment = await _mfaAccountService.BeginEnrollmentAsync(user, "SecurityService", cancellationToken); + this.QrCodeDataUri = CreateQrCodeDataUri(this.Enrollment.OtpauthUri); + await this.LoadStateAsync(cancellationToken); + return Page(); + } + + public async Task OnPostConfirmEnrollmentAsync(CancellationToken cancellationToken) + { + var user = await this.GetUserAsync(); + if (user is null) + { + return Challenge(); + } + + var confirmed = await _mfaAccountService.ConfirmEnrollmentAsync(user, Input.Code, cancellationToken); + StatusMessage = confirmed ? "MFA has been enabled." : "The authenticator code was invalid."; + await this.LoadStateAsync(cancellationToken); + return Page(); + } + + public async Task OnPostRemoveAsync(CancellationToken cancellationToken) + { + var user = await this.GetUserAsync(); + if (user is null) + { + return Challenge(); + } + + var removed = await _mfaAccountService.RemoveAuthenticatorAsync(user, Input.CurrentPassword, Input.Code, cancellationToken); + StatusMessage = removed ? "MFA has been removed." : "The password or authenticator code was invalid."; + await this.LoadStateAsync(cancellationToken); + return Page(); + } + + private async Task GetUserAsync() => await _userManager.GetUserAsync(this.User); + + private async Task LoadStateAsync(CancellationToken _) + { + var user = await this.GetUserAsync(); + this.IsMfaEnabled = user is not null && await _userManager.GetTwoFactorEnabledAsync(user); + } + + private static string CreateQrCodeDataUri(string value) + { + using var generator = new QRCodeGenerator(); + using var qrCodeData = generator.CreateQrCode(value, QRCodeGenerator.ECCLevel.Q); + var png = new PngByteQRCode(qrCodeData).GetGraphic(12); + return $"data:image/png;base64,{Convert.ToBase64String(png)}"; + } + + public sealed class InputModel + { + public string Code { get; set; } = string.Empty; + + public string CurrentPassword { get; set; } = string.Empty; + } +} diff --git a/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml b/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml new file mode 100644 index 00000000..f55a00d8 --- /dev/null +++ b/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml @@ -0,0 +1,46 @@ +@page +@model SecurityService.Pages.Account.ManageMfa.RecoveryCodesModel +@{ + ViewData["Title"] = "Recovery codes"; +} + +
+
+

Recovery codes

+

Use a recovery code if you cannot access your authenticator app.

+
+ + @if (string.IsNullOrWhiteSpace(Model.StatusMessage) == false) + { +
@Model.StatusMessage
+ } + +
+
+ @if (Model.RecoveryCodes.Count > 0) + { +

Save these codes now. They will not be displayed again.

+
+ @foreach (var code in Model.RecoveryCodes) + { + @code + } +
+ } + else + { +

Generating a new set revokes all previous recovery codes.

+ } + +
+
+ +
+
+ +
+ Back to MFA +
+
+
+
diff --git a/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml.cs b/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml.cs new file mode 100644 index 00000000..efe518fc --- /dev/null +++ b/SecurityService/Pages/Account/ManageMfa/RecoveryCodes.cshtml.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SecurityService.BusinessLogic.Mfa; +using SecurityService.Database.Entities; + +namespace SecurityService.Pages.Account.ManageMfa; + +[Authorize] +public sealed class RecoveryCodesModel : PageModel +{ + private readonly UserManager _userManager; + private readonly MfaAccountService _mfaAccountService; + + public RecoveryCodesModel(UserManager userManager, MfaAccountService mfaAccountService) + { + _userManager = userManager; + _mfaAccountService = mfaAccountService; + } + + public IReadOnlyList RecoveryCodes { get; private set; } = Array.Empty(); + + public string StatusMessage { get; private set; } = string.Empty; + + public async Task OnPostGenerateAsync(CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(this.User); + if (user is null) + { + return Challenge(); + } + + RecoveryCodes = await _mfaAccountService.GenerateRecoveryCodesAsync(user, 10, cancellationToken); + StatusMessage = "Store these recovery codes securely. They will not be shown again."; + return Page(); + } + + public async Task OnPostRevokeAsync(CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(this.User); + if (user is null) + { + return Challenge(); + } + + await _mfaAccountService.RevokeRecoveryCodesAsync(user, cancellationToken); + StatusMessage = "Recovery codes have been revoked."; + return Page(); + } +} diff --git a/SecurityService/Pages/Shared/_Nav.cshtml b/SecurityService/Pages/Shared/_Nav.cshtml index c6350038..e91770aa 100644 --- a/SecurityService/Pages/Shared/_Nav.cshtml +++ b/SecurityService/Pages/Shared/_Nav.cshtml @@ -18,6 +18,7 @@