diff --git a/SecurityService.BusinessLogic/RequestHandlers/LoginRequestHandler.cs b/SecurityService.BusinessLogic/RequestHandlers/LoginRequestHandler.cs new file mode 100644 index 00000000..33e8af70 --- /dev/null +++ b/SecurityService.BusinessLogic/RequestHandlers/LoginRequestHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using SecurityService.BusinessLogic.Requests; +using SecurityService.Database; +using SecurityService.Models; +using SimpleResults; + +namespace SecurityService.BusinessLogic.RequestHandlers; + +public sealed class LoginRequestHandler : + IRequestHandler>>, + IRequestHandler +{ + private readonly SignInManager _signInManager; + + public LoginRequestHandler(SignInManager signInManager) + { + this._signInManager = signInManager; + } + + public async Task>> Handle(SecurityServiceQueries.GetExternalProvidersQuery query, CancellationToken cancellationToken) + { + var providers = (await this._signInManager.GetExternalAuthenticationSchemesAsync()) + .Select(scheme => new ExternalProviderDetails(scheme.Name, scheme.DisplayName ?? scheme.Name)) + .OrderBy(provider => provider.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return Result.Success(providers); + } + + public async Task Handle(SecurityServiceCommands.LoginCommand command, CancellationToken cancellationToken) + { + var result = await this._signInManager.PasswordSignInAsync(command.Username, command.Password, command.RememberLogin, lockoutOnFailure: true); + + if (result.Succeeded) + { + return Result.Success(); + } + + if (result.IsLockedOut) + { + return Result.Failure("Your account has been locked. Please try again later or contact support."); + } + + if (result.IsNotAllowed) + { + return Result.Failure("You are not allowed to sign in. Please confirm your email address."); + } + + if (result.RequiresTwoFactor) + { + return Result.Failure("Two-factor authentication is required."); + } + + return Result.Failure("Invalid username or password."); + } +} diff --git a/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs b/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs index b5dc3ac8..eca95f21 100644 --- a/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs +++ b/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs @@ -67,4 +67,6 @@ public record ProcessPasswordResetConfirmationCommand(String Username, String Token, String Password, String ClientId) : IRequest>; + + public sealed record LoginCommand(string Username, string Password, bool RememberLogin) : IRequest; } diff --git a/SecurityService.BusinessLogic/Requests/SecurityServiceQueries.cs b/SecurityService.BusinessLogic/Requests/SecurityServiceQueries.cs index 2eb249c7..0b8c54f1 100644 --- a/SecurityService.BusinessLogic/Requests/SecurityServiceQueries.cs +++ b/SecurityService.BusinessLogic/Requests/SecurityServiceQueries.cs @@ -22,4 +22,5 @@ public sealed record GetRoleQuery(string RoleId) : IRequest> public sealed record GetRolesQuery() : IRequest>>; public sealed record GetUserQuery(string UserId) : IRequest>; public sealed record GetUsersQuery(string? UserName) : IRequest>>; + public sealed record GetExternalProvidersQuery() : IRequest>>; } diff --git a/SecurityService.Models/DetailsModels.cs b/SecurityService.Models/DetailsModels.cs index 0840f58b..c9477966 100644 --- a/SecurityService.Models/DetailsModels.cs +++ b/SecurityService.Models/DetailsModels.cs @@ -48,3 +48,5 @@ public sealed record UserDetails( string? FamilyName, IReadOnlyDictionary Claims, IReadOnlyCollection Roles); + +public sealed record ExternalProviderDetails(string Name, string DisplayName); diff --git a/SecurityService.UnitTests/Pages/LoginPageModelTests.cs b/SecurityService.UnitTests/Pages/LoginPageModelTests.cs index 250203ea..4eccd309 100644 --- a/SecurityService.UnitTests/Pages/LoginPageModelTests.cs +++ b/SecurityService.UnitTests/Pages/LoginPageModelTests.cs @@ -1,10 +1,12 @@ +using MediatR; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Moq; -using SecurityService.Database; +using SecurityService.BusinessLogic.Requests; +using SecurityService.Models; using SecurityService.UnitTests.Infrastructure; using Shouldly; +using SimpleResults; namespace SecurityService.UnitTests.Pages; @@ -13,9 +15,11 @@ public class LoginPageModelTests [Fact] public async Task OnPost_WhenForgotPasswordSelected_RedirectsToForgotPasswordWithReturnUrlAndClientId() { - var userManager = IdentityMocks.CreateUserManager(); - var signInManager = IdentityMocks.CreateSignInManager(userManager); - var model = CreateModel(signInManager, new DefaultHttpContext()); + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new List())); + + var model = CreateModel(mediator, new DefaultHttpContext()); model.Input = new SecurityService.Pages.Account.Login.IndexModel.InputModel { Button = "forgotpassword", @@ -30,16 +34,12 @@ public async Task OnPost_WhenForgotPasswordSelected_RedirectsToForgotPasswordWit redirect.RouteValues.ShouldNotBeNull(); redirect.RouteValues["returnUrl"].ShouldBe("/connect/authorize?client_id=test-client-id"); redirect.RouteValues["clientId"].ShouldBe("test-client-id"); - signInManager.Verify(instance => instance.PasswordSignInAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); + mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never); } - private static SecurityService.Pages.Account.Login.IndexModel CreateModel(Mock> signInManager, HttpContext httpContext) + private static SecurityService.Pages.Account.Login.IndexModel CreateModel(Mock mediator, HttpContext httpContext) { - return new SecurityService.Pages.Account.Login.IndexModel(signInManager.Object) + return new SecurityService.Pages.Account.Login.IndexModel(mediator.Object) { PageContext = new Microsoft.AspNetCore.Mvc.RazorPages.PageContext { @@ -48,3 +48,4 @@ private static SecurityService.Pages.Account.Login.IndexModel CreateModel(Mock _signInManager; + private readonly IMediator _mediator; - public IndexModel(SignInManager signInManager) + public IndexModel(IMediator mediator) { - this._signInManager = signInManager; + this._mediator = mediator; } [BindProperty] @@ -52,22 +52,22 @@ public async Task OnPostAsync() }); } - var result = await this._signInManager.PasswordSignInAsync(this.Input.Username, this.Input.Password, this.Input.RememberLogin, lockoutOnFailure: true); - if (result.Succeeded) + var result = await this._mediator.Send(new SecurityServiceCommands.LoginCommand(this.Input.Username, this.Input.Password, this.Input.RememberLogin)); + if (result.IsSuccess) { return this.LocalRedirect(string.IsNullOrWhiteSpace(this.Input.ReturnUrl) ? "/" : this.Input.ReturnUrl); } - this.ModelState.AddModelError(string.Empty, "Invalid username or password."); + this.ModelState.AddModelError(string.Empty, result.Errors.FirstOrDefault() ?? result.Message ?? "Invalid username or password."); return this.Page(); } private async Task LoadProvidersAsync() { - this.Providers = (await this._signInManager.GetExternalAuthenticationSchemesAsync()) - .Select(scheme => new ExternalProviderViewModel(scheme.Name, scheme.DisplayName ?? scheme.Name)) - .OrderBy(provider => provider.DisplayName, StringComparer.OrdinalIgnoreCase) - .ToArray(); + var result = await this._mediator.Send(new SecurityServiceQueries.GetExternalProvidersQuery()); + this.Providers = result.IsSuccess + ? result.Data.Select(p => new ExternalProviderViewModel(p.Name, p.DisplayName)).ToArray() + : Array.Empty(); } public sealed class InputModel @@ -83,3 +83,4 @@ public sealed class InputModel public sealed record ExternalProviderViewModel(string Name, string DisplayName); } +