From 3a81a1818d223d837a782031fbcc0a6bc4689064 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 22 May 2026 11:47:37 +0100 Subject: [PATCH] Add ResendWelcomeEmailCommand and refactor email resend Introduce ResendWelcomeEmailCommand and implement its handler in UserRequestHandler to support resending welcome emails with password reset. Refactor ResendConfirmationEmail page to use MediatR and the new command. Improve logging and error handling, clean up unused code, update project references, and apply minor code style improvements. --- .../RequestHandlers/UserRequestHandler.cs | 93 ++++++++++++------- .../Requests/SecurityServiceCommands.cs | 1 + .../SecurityService.BusinessLogic.csproj | 1 + .../SecurityServiceOptions.cs | 1 - .../Factories/OidcResponseFactory.cs | 46 --------- .../ResendConfirmationEmail/Index.cshtml.cs | 34 ++----- .../Account/ResetPassword/Index.cshtml.cs | 71 -------------- SecurityService/Program.cs | 30 ------ SecurityService/SecurityService.csproj | 3 - 9 files changed, 69 insertions(+), 211 deletions(-) diff --git a/SecurityService.BusinessLogic/RequestHandlers/UserRequestHandler.cs b/SecurityService.BusinessLogic/RequestHandlers/UserRequestHandler.cs index d2440006..fc694dab 100644 --- a/SecurityService.BusinessLogic/RequestHandlers/UserRequestHandler.cs +++ b/SecurityService.BusinessLogic/RequestHandlers/UserRequestHandler.cs @@ -20,6 +20,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Encodings.Web; +using Shared.Logger; using static Microsoft.EntityFrameworkCore.DbLoggerCategory.Database; namespace SecurityService.BusinessLogic.RequestHandlers; @@ -63,23 +64,12 @@ private async Task GetToken() { // Get a token to talk to the estate service String clientId = this.Options.Value.ClientId; - - //Logger.LogInformation($"Client Id is {clientId}"); - //Logger.LogInformation($"Client Secret is {clientSecret}"); if (this.TokenResponse == null) { String clientToken = this.ClientJwtService.CreateClientAssertion(clientId, this.Options.Value.IssuerUrl, 60); this.TokenResponse = TokenResponse.Create(clientToken, null, 3600, DateTimeOffset.Now, DateTimeOffset.Now.AddSeconds(3600)); - // Logger.LogInformation($"Token is {this.TokenResponse.AccessToken}"); - return this.TokenResponse; } - if (this.TokenResponse.Expires.UtcDateTime.Subtract(DateTime.UtcNow) < TimeSpan.FromMinutes(2)) - { - // Logger.LogInformation($"Token is about to expire at {this.TokenResponse.Expires.DateTime:O}"); - // Logger.LogInformation($"Token is {this.TokenResponse.AccessToken}"); - return this.TokenResponse; - } return this.TokenResponse; } @@ -94,15 +84,18 @@ private async Task SendConfirmationEmail(ApplicationUser newIdentityUser SendEmailRequest emailRequest = this.BuildEmailConfirmationRequest(newIdentityUser, uri); Result sendEmailResult = await this.MessagingServiceClient.SendEmail(token.AccessToken, emailRequest, cancellationToken); // TODO: Error handling - //if (sendEmailResult.IsFailed) - //Logger.LogWarning($"Error sending email to {newIdentityUser.Email} as part of user creation {sendEmailResult}"); + if (sendEmailResult.IsFailed) { + Logger.LogWarning($"Error sending email to {newIdentityUser.Email} as part of user creation {sendEmailResult}"); + return ResultHelpers.CreateFailure(sendEmailResult); + } + return Result.Success(); } private SendEmailRequest BuildEmailConfirmationRequest(ApplicationUser user, String emailConfirmationToken) { - StringBuilder mesasgeBuilder = new StringBuilder(); + StringBuilder mesasgeBuilder = new(); mesasgeBuilder.Append(""); mesasgeBuilder.Append(""); @@ -414,7 +407,46 @@ public async Task Handle(SecurityServiceCommands.SendWelcomeEmailCommand return Result.Success(); } - + public async Task Handle(SecurityServiceCommands.ResendWelcomeEmailCommand command, + CancellationToken cancellationToken) + { + ApplicationUser? user = await this.UserManager.FindByNameAsync(command.Username); + if (user == null) + { + return Result.NotFound($"No user found with username {command.Username}"); + } + + IdentityResult removePasswordResult = await this.UserManager.RemovePasswordAsync(user); + if (removePasswordResult.Succeeded == false) + { + return Result.Failure($"Errors removing password for user [{command.Username}]"); + } + + Result generatedPasswordResult = PasswordGenerator.GenerateRandomPassword(this.Options.Value.PasswordOptions); + if (generatedPasswordResult.IsFailed) + { + return ResultHelpers.CreateFailure(generatedPasswordResult); + } + + IdentityResult addPasswordResult = await this.UserManager.AddPasswordAsync(user, generatedPasswordResult.Data); + if (addPasswordResult.Succeeded == false) + { + return Result.Failure($"Errors adding password for user [{command.Username}]"); + } + + TokenResponse token = await this.GetToken(); + string emailAddress = user.Email ?? user.UserName ?? string.Empty; + SendEmailRequest emailRequest = this.BuildWelcomeEmail(emailAddress, generatedPasswordResult.Data); + Result sendEmailResult = await this.MessagingServiceClient.SendEmail(token.AccessToken, emailRequest, cancellationToken); + if (sendEmailResult.IsFailed) + { + return ResultHelpers.CreateFailure(sendEmailResult); + } + + return Result.Success(); + } + + public static class PasswordGenerator { @@ -489,17 +521,14 @@ private static void SecureShuffle(List chars) { public async Task> Handle(SecurityServiceCommands.ChangeUserPasswordCommand command, CancellationToken cancellationToken) { - //Logger.LogWarning("In Handle ChangeUserPasswordCommand"); // Find the user based on the user name passed in ApplicationUser user = await this.UserManager.FindByNameAsync(command.UserName); - if (user == null) - { - //Logger.LogWarning("In Handle ChangeUserPasswordCommand - user is null"); + if (user == null) { // this prevents giving away info to a potential hacker... return Result.NotFound(); } - //Logger.LogWarning("In Handle ChangeUserPasswordCommand - user is not null"); + IdentityResult result = await this.UserManager.ChangePasswordAsync(user, command.CurrentPassword, command.NewPassword); @@ -507,27 +536,22 @@ public async Task> Handle(SecurityServiceComman if (result.Succeeded == false) { // Log any errors - ///Logger.LogInformation($"Errors during password change for user [{command.UserName} and Client [{command.ClientId}]"); + StringBuilder errors = new StringBuilder(); foreach (IdentityError identityError in result.Errors) { - //Logger.LogInformation($"Code {identityError.Code} Description {identityError.Description}"); + errors.AppendLine($"Code {identityError.Code} Description {identityError.Description}"); } - return Result.Failure($"Errors during password change for user [{command.UserName} and Client [{command.ClientId}]"); + return Result.Failure($"Errors during password change for user [{command.UserName}] and Client [{command.ClientId}]: {errors}"); } - //Logger.LogWarning("In Handle ChangeUserPasswordCommand - password changed"); // build the redirect uri var client = await this.DbContext.ClientDefinitions.Where(c => c.ClientId == command.ClientId).SingleOrDefaultAsync(cancellationToken); - if (client == null) - { - //Logger.LogWarning("In Handle ChangeUserPasswordCommand - client not found"); - //Logger.LogInformation($"Client not found for clientId {command.ClientId}"); + if (client == null) { return Result.Invalid($"Client not found for clientId {command.ClientId}"); } - //Logger.LogWarning($"Client uri: {client.ClientUri}"); return Result.Success(new ChangeUserPasswordResult { IsSuccessful = true, RedirectUri = client.ClientUri }); } @@ -536,8 +560,7 @@ public async Task Handle(SecurityServiceCommands.ProcessPasswordResetReq // Find the user based on the user name passed in ApplicationUser user = await this.UserManager.FindByNameAsync(command.Username); - if (user == null) - { + if (user == null) { return Result.Success(); } @@ -594,7 +617,6 @@ public async Task> Handle(SecurityServiceCommands.ProcessPassword if (user == null) { // this prevents giving away info to a potential hacker... - //Logger.LogInformation($"user not found for username {command.Username}"); return Result.NotFound($"user not found for username {command.Username}"); } @@ -604,13 +626,13 @@ public async Task> Handle(SecurityServiceCommands.ProcessPassword if (result.Succeeded == false) { // Log any errors - //Logger.LogInformation($"Errors during password reset for user [{command.Username} and Client [{command.ClientId}]"); + StringBuilder errors = new StringBuilder(); foreach (IdentityError identityError in result.Errors) { - //Logger.LogInformation($"Code {identityError.Code} Description {identityError.Description}"); + errors.AppendLine($"Code {identityError.Code} Description {identityError.Description}"); } - return Result.Failure($"Errors during password reset for user [{command.Username} and Client [{command.ClientId}]"); + return Result.Failure($"Errors during password reset for user [{command.Username}] and Client [{command.ClientId}]: {errors}"); } // build the redirect uri @@ -618,7 +640,6 @@ public async Task> Handle(SecurityServiceCommands.ProcessPassword if (client == null) { - //Logger.LogInformation($"Client not found for clientId {command.ClientId}"); return Result.Invalid($"Client not found for clientId {command.ClientId}"); } diff --git a/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs b/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs index 02fb9ec9..edf0b21a 100644 --- a/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs +++ b/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs @@ -56,6 +56,7 @@ public record ChangeUserPasswordCommand(String UserName, public record ConfirmUserEmailAddressCommand(String UserName, String ConfirmEmailToken) : IRequest; public record SendWelcomeEmailCommand(String Username) : IRequest; + public record ResendWelcomeEmailCommand(String Username) : IRequest; public record ProcessPasswordResetRequestCommand(String Username, String EmailAddress, String ClientId) : IRequest; diff --git a/SecurityService.BusinessLogic/SecurityService.BusinessLogic.csproj b/SecurityService.BusinessLogic/SecurityService.BusinessLogic.csproj index 6b3cc2ce..c8e39e83 100644 --- a/SecurityService.BusinessLogic/SecurityService.BusinessLogic.csproj +++ b/SecurityService.BusinessLogic/SecurityService.BusinessLogic.csproj @@ -13,6 +13,7 @@ + diff --git a/SecurityService.BusinessLogic/SecurityServiceOptions.cs b/SecurityService.BusinessLogic/SecurityServiceOptions.cs index c4973adb..88733fe2 100644 --- a/SecurityService.BusinessLogic/SecurityServiceOptions.cs +++ b/SecurityService.BusinessLogic/SecurityServiceOptions.cs @@ -118,7 +118,6 @@ public async Task SendEmail(String accessToken, SendEmailRequest request, CancellationToken cancellationToken) { - //Logger.LogWarning($"Sending Email {request.Subject}"); this.LastEmailRequest = new SendEmailRequest() { Body = request.Body, ConnectionIdentifier = request.ConnectionIdentifier, diff --git a/SecurityService/Factories/OidcResponseFactory.cs b/SecurityService/Factories/OidcResponseFactory.cs index 71db600d..7154606e 100644 --- a/SecurityService/Factories/OidcResponseFactory.cs +++ b/SecurityService/Factories/OidcResponseFactory.cs @@ -56,50 +56,4 @@ internal static IResult TranslateResultStatus(ResultBase result) _ => Microsoft.AspNetCore.Http.Results.StatusCode((int)HttpStatusCode.NotImplemented) }; } - - //public static IResult FromResult(Result result) - //{ - // if (result.IsSuccess == false) - // { - // return TranslateResultStatus(result); - // } - - // return result.Data switch - // { - // TokenSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), - // TokenForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), - // TokenBadRequestResult r => Results.BadRequest(r.Error), - // _ => Results.StatusCode(500) - // }; - //} - - //public static IResult FromResult(Result result) - //{ - // if (result.IsSuccess == false) - // { - // return TranslateResultStatus(result); - // } - - // return result.Data switch - // { - // LogoutSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), - // LogoutRedirectResult r => Results.Redirect(r.Url), - // _ => Results.StatusCode(500) - // }; - //} - - //public static IResult FromResult(Result result) - //{ - // if (result.IsSuccess == false) - // { - // return TranslateResultStatus(result); - // } - - // return result.Data switch - // { - // UserInfoJsonResult r => Results.Json(r.Data), - // UserInfoChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), - // _ => Results.StatusCode(500) - // }; - //} } diff --git a/SecurityService/Pages/Account/ResendConfirmationEmail/Index.cshtml.cs b/SecurityService/Pages/Account/ResendConfirmationEmail/Index.cshtml.cs index 9db216e4..547d7c9c 100644 --- a/SecurityService/Pages/Account/ResendConfirmationEmail/Index.cshtml.cs +++ b/SecurityService/Pages/Account/ResendConfirmationEmail/Index.cshtml.cs @@ -1,19 +1,16 @@ -using System.Text; -using Microsoft.AspNetCore.Identity; +using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.AspNetCore.WebUtilities; -using SecurityService.Database; +using SecurityService.BusinessLogic.Requests; namespace SecurityService.Pages.Account.ResendConfirmationEmail; public sealed class IndexModel : PageModel { - private readonly UserManager _userManager; - - public IndexModel(UserManager userManager) - { - this._userManager = userManager; + private readonly IMediator Mediator; + + public IndexModel(IMediator mediator) { + this.Mediator = mediator; } [BindProperty] @@ -29,26 +26,15 @@ public async Task OnPostAsync(CancellationToken cancellationToken return this.Page(); } - var user = await this.ResolveUserAsync(this.Input.EmailOrUserName); - if (user is not null && string.IsNullOrWhiteSpace(user.Email) == false && user.EmailConfirmed == false) - { - var code = await this._userManager.GenerateEmailConfirmationTokenAsync(user); - var encoded = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - var callbackUrl = this.Url.Page("/Account/ConfirmEmail/Index", null, new { userId = user.Id, code = encoded }, this.Request.Scheme) ?? $"/Account/ConfirmEmail?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(encoded)}"; - // TODO: Send email with callback URL - //await this._emailService.SendAsync(new AccountMessage(user.Email!, "Confirm your email", $"Confirm your account by visiting {callbackUrl}"), cancellationToken); - } + SecurityServiceCommands.ResendWelcomeEmailCommand command = new(this.Input.EmailOrUserName); + await this.Mediator.Send(command, cancellationToken); + + this.EmailQueued = true; return this.Page(); } - private async Task ResolveUserAsync(string value) - { - var user = await this._userManager.FindByEmailAsync(value); - return user ?? await this._userManager.FindByNameAsync(value); - } - public sealed class InputModel { public string EmailOrUserName { get; set; } = string.Empty; diff --git a/SecurityService/Pages/Account/ResetPassword/Index.cshtml.cs b/SecurityService/Pages/Account/ResetPassword/Index.cshtml.cs index 86db3306..6311a3f9 100644 --- a/SecurityService/Pages/Account/ResetPassword/Index.cshtml.cs +++ b/SecurityService/Pages/Account/ResetPassword/Index.cshtml.cs @@ -18,77 +18,6 @@ public sealed class IndexModel : PageModel { private const string DontMatchMessage = "Password does not match Confirm Password"; - //private readonly UserManager _userManager; - - //public IndexModel(UserManager userManager) - //{ - // this._userManager = userManager; - //} - - //[BindProperty] - //public InputModel Input { get; set; } = new(); - - //public string StatusMessage { get; private set; } = string.Empty; - - //public void OnGet(string? userId, string? code) - //{ - // if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(code)) - // { - // this.StatusMessage = "The password reset link is invalid."; - // return; - // } - - // this.Input.UserId = userId; - // this.Input.Code = code; - //} - - //public async Task OnPostAsync() - //{ - // if (string.IsNullOrWhiteSpace(this.Input.UserId) || string.IsNullOrWhiteSpace(this.Input.Code)) - // { - // this.ModelState.AddModelError(string.Empty, "The password reset request is invalid."); - // return this.Page(); - // } - - // if (string.Equals(this.Input.NewPassword, this.Input.ConfirmPassword, StringComparison.Ordinal) == false) - // { - // this.ModelState.AddModelError(string.Empty, "The password confirmation does not match."); - // return this.Page(); - // } - - // var user = await this._userManager.FindByIdAsync(this.Input.UserId); - // if (user is null) - // { - // this.StatusMessage = "The password has been reset if the account exists."; - // return this.Page(); - // } - - // var decoded = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(this.Input.Code)); - // var result = await this._userManager.ResetPasswordAsync(user, decoded, this.Input.NewPassword); - // if (result.Succeeded == false) - // { - // foreach (var error in result.Errors) - // { - // this.ModelState.AddModelError(string.Empty, error.Description); - // } - - // return this.Page(); - // } - - // this.StatusMessage = "The password has been reset. You can now sign in."; - // return this.Page(); - //} - - //public sealed class InputModel - //{ - // public string UserId { get; set; } = string.Empty; - - // public string Code { get; set; } = string.Empty; - - // public string NewPassword { get; set; } = string.Empty; - - // public string ConfirmPassword { get; set; } = string.Empty; - //} private readonly IMediator Mediator; public ViewModel View { get; set; } diff --git a/SecurityService/Program.cs b/SecurityService/Program.cs index a42f6066..5b149e87 100644 --- a/SecurityService/Program.cs +++ b/SecurityService/Program.cs @@ -60,7 +60,6 @@ var builtConfig = configBuilder.Build(); // Keep existing static usage (if you must), and initialise the ConfigurationReader now. - //builder.Configuration = builtConfig; ConfigurationReader.Initialise(builder.Configuration); // Configure Sentry on the webBuilder using the config snapshot. @@ -151,35 +150,6 @@ cookieOptions.LogoutPath = "/Account/Logout"; }); -//AuthenticationBuilder authenticationBuilder = builder.Services.AddAuthentication(); -//foreach (var provider in options.ExternalProviders.Where(provider => provider.Enabled)) -//{ -// authenticationBuilder.AddOpenIdConnect(provider.Scheme, string.IsNullOrWhiteSpace(provider.DisplayName) ? provider.Scheme : provider.DisplayName, openIdOptions => -// { -// openIdOptions.SignInScheme = IdentityConstants.ExternalScheme; -// openIdOptions.Authority = provider.Authority; -// openIdOptions.ClientId = provider.ClientId; -// openIdOptions.ClientSecret = provider.ClientSecret; -// openIdOptions.CallbackPath = provider.CallbackPath; -// openIdOptions.ResponseType = "code"; -// openIdOptions.SaveTokens = true; -// openIdOptions.GetClaimsFromUserInfoEndpoint = true; -// openIdOptions.MapInboundClaims = false; -// openIdOptions.Scope.Clear(); -// openIdOptions.Scope.Add("openid"); -// openIdOptions.Scope.Add("profile"); -// openIdOptions.Scope.Add("email"); - -// foreach (var scope in provider.Scopes.Where(scope => string.IsNullOrWhiteSpace(scope) == false).Distinct(StringComparer.OrdinalIgnoreCase)) -// { -// if (openIdOptions.Scope.Contains(scope, StringComparer.OrdinalIgnoreCase) == false) -// { -// openIdOptions.Scope.Add(scope); -// } -// } -// }); -//} - builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); diff --git a/SecurityService/SecurityService.csproj b/SecurityService/SecurityService.csproj index 86993fe6..45ad9cbf 100644 --- a/SecurityService/SecurityService.csproj +++ b/SecurityService/SecurityService.csproj @@ -8,11 +8,9 @@ - - @@ -69,6 +67,5 @@ -