diff --git a/src/cCoder.Security.Data/Models/DTOs/ChangePasswordRequest.cs b/src/cCoder.Security.Data/Models/DTOs/ChangePasswordRequest.cs index 3d0c30f..2329561 100644 --- a/src/cCoder.Security.Data/Models/DTOs/ChangePasswordRequest.cs +++ b/src/cCoder.Security.Data/Models/DTOs/ChangePasswordRequest.cs @@ -8,5 +8,8 @@ public class ChangePasswordRequest { public string OldPassword { get; set; } public string NewPassword { get; set; } + + public string ConfirmPassword { get; set; } + public string Token { get; set; } } \ No newline at end of file diff --git a/src/cCoder.Security.Data/project.stxjson b/src/cCoder.Security.Data/project.stxjson index 97d7b53..4150bb8 100644 --- a/src/cCoder.Security.Data/project.stxjson +++ b/src/cCoder.Security.Data/project.stxjson @@ -2076,6 +2076,10 @@ "BaseType": null, "Interfaces": [], "Properties": [ + { + "Name": "ConfirmPassword", + "Type": "System.String" + }, { "Name": "NewPassword", "Type": "System.String" diff --git a/src/cCoder.Security.Tests/Aggregations/CurrentUserAggregationServiceTests.GetCurrentUser.cs b/src/cCoder.Security.Tests/Aggregations/CurrentUserAggregationServiceTests.GetCurrentUser.cs index ddc2fb9..1734227 100644 --- a/src/cCoder.Security.Tests/Aggregations/CurrentUserAggregationServiceTests.GetCurrentUser.cs +++ b/src/cCoder.Security.Tests/Aggregations/CurrentUserAggregationServiceTests.GetCurrentUser.cs @@ -95,4 +95,119 @@ public void MeReturnsCurrentUserWithoutProtectedFields() ssoUserProcessingServiceMock.Verify(expression: service => service.Me(), times: Times.Once); } + + [Fact] + public async Task UpdateMeChangesOnlyEditableProfileFields() + { + // Given + SSOUser storedUser = new() + { + Id = "existing.user", + DisplayName = "Old name", + Email = "old@example.com", + PhoneNumber = "0123", + PasswordHash = "stored-hash", + AccessFailedCount = 2, + EmailConfirmed = true, + LockoutEnabled = false + }; + + SSOUser request = new() + { + Id = "attempted.identity.change", + DisplayName = "New name", + Email = "new@example.com", + PhoneNumber = "0456", + PasswordHash = "attempted-password-change", + AccessFailedCount = 99, + LockoutEnabled = true + }; + + Mock service = new(MockBehavior.Strict); + + service + .Setup(expression: item => item.Me()) + .Returns(value: storedUser); + + service + .Setup(expression: item => item.UpdateSSOUserAsync( + item: It.IsAny())) + .Returns(value: new ValueTask(result: storedUser)); + + ICurrentUserAggregationService manager = + new CurrentUserAggregationService( + ssoUserProcessingService: service.Object, + authInfo: new SSOAuthInfo + { + SSOUserId = storedUser.Id + }); + + // When + SSOUser result = await manager.UpdateCurrentSSOUserAsync( + updatedUser: request); + + // Then + storedUser.Id + .Should() + .Be(expected: "existing.user"); + + storedUser.PasswordHash + .Should() + .Be(expected: "stored-hash"); + + storedUser.AccessFailedCount + .Should() + .Be(expected: 2); + + storedUser.LockoutEnabled + .Should() + .BeFalse(); + + result.DisplayName + .Should() + .Be(expected: "New name"); + + result.Email + .Should() + .Be(expected: "new@example.com"); + + result.PhoneNumber + .Should() + .Be(expected: "0456"); + + result.PasswordHash + .Should() + .BeNull(); + } + + [Fact] + public async Task UpdateMeRejectsGuestBeforeUserLookup() + { + // Given + Mock service = new(MockBehavior.Strict); + + ICurrentUserAggregationService manager = + new CurrentUserAggregationService( + ssoUserProcessingService: service.Object, + authInfo: new SSOAuthInfo + { + SSOUserId = "Guest" + }); + + // When + Func updateCurrentUser = async () => + await manager.UpdateCurrentSSOUserAsync( + updatedUser: new SSOUser + { + DisplayName = "Guest", + Email = "guest@example.com" + }); + + // Then + await updateCurrentUser + .Should() + .ThrowAsync(); + + service.VerifyNoOtherCalls(); + } } \ No newline at end of file diff --git a/src/cCoder.Security.Tests/ControllerHttpComplianceTests.ChangePasswordFailures.cs b/src/cCoder.Security.Tests/ControllerHttpComplianceTests.ChangePasswordFailures.cs new file mode 100644 index 0000000..64e4d23 --- /dev/null +++ b/src/cCoder.Security.Tests/ControllerHttpComplianceTests.ChangePasswordFailures.cs @@ -0,0 +1,136 @@ +// --------------------------------------------------------------- +// Copyright (c) Paul.Ward@ccoder.co.uk +// --------------------------------------------------------------- + +using cCoder.Security.Exposures; +using cCoder.Security.Exposures.Controllers; +using cCoder.Security.Models.DTOs; +using cCoder.Security.Models.Entities; +using cCoder.Security.Models.Exceptions; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using System.Security; +using System.Security.Claims; +using Xunit; + +namespace cCoder.Security.Tests; + +public sealed partial class ControllerHttpComplianceTests +{ + [Fact] + public async Task PostChangePassword_WhenConfirmationDiffers_ShouldReturnBadRequest() + { + // Given + (AuthenticationController controller, Mock manager) = + CreateAuthenticatedPasswordController(); + + ChangePasswordRequest request = CreatePasswordRequest(); + request.ConfirmPassword = "different-password"; + + // When + IActionResult result = await controller.PostChangePassword( + newChangePasswordRequest: request); + + // Then + result + .Should() + .BeOfType(); + + manager.Verify( + expression: service => service.ChangePasswordAsync( + username: It.IsAny(), + oldPassword: It.IsAny(), + newPassword: It.IsAny()), + times: Times.Never); + } + + [Theory] + [InlineData("authentication", StatusCodes.Status401Unauthorized)] + [InlineData("validation", StatusCodes.Status400BadRequest)] + [InlineData("security", StatusCodes.Status401Unauthorized)] + [InlineData("dependency", StatusCodes.Status503ServiceUnavailable)] + [InlineData("unexpected", StatusCodes.Status500InternalServerError)] + public async Task PostChangePassword_WhenServiceFails_ShouldReturnSafeStatus( + string failure, + int expectedStatus) + { + // Given + (AuthenticationController controller, Mock manager) = + CreateAuthenticatedPasswordController(); + + Exception exception = failure switch + { + "authentication" => new SecurityAggregationAuthenticationException( + innerException: new SecurityException()), + "validation" => new SecurityAggregationValidationException( + innerException: new ArgumentException()), + "security" => new SecurityAggregationServiceException( + innerException: new Exception( + message: "wrapped", + innerException: new SecurityException())), + "dependency" => new SecurityAggregationDependencyException( + innerException: new InvalidOperationException()), + _ => new InvalidOperationException() + }; + + manager + .Setup(expression: service => service.ChangePasswordAsync( + username: "current.user", + oldPassword: "old-password", + newPassword: "new-password")) + .Returns(value: ValueTask.FromException(exception: exception)); + + // When + IActionResult result = await controller.PostChangePassword( + newChangePasswordRequest: CreatePasswordRequest()); + + // Then + int actualStatus = result switch + { + ChallengeResult => StatusCodes.Status401Unauthorized, + ObjectResult objectResult => objectResult.StatusCode!.Value, + _ => throw new InvalidOperationException( + message: $"Unexpected result type {result.GetType().Name}.") + }; + + actualStatus.Should() + .Be(expected: expectedStatus); + } + + private static ( + AuthenticationController Controller, + Mock Manager) + CreateAuthenticatedPasswordController() + { + Mock authenticationManager = new(); + Mock currentUserManager = new(); + + currentUserManager + .Setup(expression: manager => manager.GetCurrentUser()) + .Returns(value: new SSOUser { Id = "current.user" }); + + AuthenticationController controller = new( + authenticationAggregationService: authenticationManager.Object, + currentUserManager: currentUserManager.Object); + + controller.ControllerContext.HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal( + identity: new ClaimsIdentity( + claims: [], + authenticationType: "Test")) + }; + + return (controller, authenticationManager); + } + + private static ChangePasswordRequest CreatePasswordRequest() => + new() + { + ConfirmPassword = "new-password", + NewPassword = "new-password", + OldPassword = "old-password" + }; +} \ No newline at end of file diff --git a/src/cCoder.Security.Tests/ControllerHttpComplianceTests.cs b/src/cCoder.Security.Tests/ControllerHttpComplianceTests.cs index fbefd19..2ecb641 100644 --- a/src/cCoder.Security.Tests/ControllerHttpComplianceTests.cs +++ b/src/cCoder.Security.Tests/ControllerHttpComplianceTests.cs @@ -10,6 +10,8 @@ using cCoder.Security.Models.Exceptions; using cCoder.Security.Services.Aggregations.Interfaces; using FluentAssertions; +using System.Security; +using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; @@ -34,7 +36,9 @@ public async Task PostLogin_WhenCredentialsAreInvalid_ShouldReturnUnauthorized() innerException: new Exception(message: "sensitive")))); AuthenticationController controller = - new(authenticationAggregationService: authenticationManager.Object); + new( + authenticationAggregationService: authenticationManager.Object, + currentUserManager: Mock.Of()); // When IActionResult result = await controller.PostLogin( @@ -117,4 +121,231 @@ public async Task PostTenant_WhenSuccessful_ShouldReturnCreated() .Should() .BeSameAs(expected: tenant); } + + [Fact] + public async Task PostChangePasswordUsesAuthenticatedCurrentUser() + { + // Given + Mock authenticationManager = new(); + Mock currentUserManager = new(); + + currentUserManager + .Setup(expression: manager => manager.GetCurrentUser()) + .Returns(value: new SSOUser { Id = "current.user" }); + + AuthenticationController controller = new( + authenticationAggregationService: authenticationManager.Object, + currentUserManager: currentUserManager.Object); + + controller.ControllerContext.HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal( + identity: new ClaimsIdentity( + claims: [], + authenticationType: "Test")) + }; + + ChangePasswordRequest request = new() + { + OldPassword = "old-password", + NewPassword = "new-password", + ConfirmPassword = "new-password" + }; + + // When + IActionResult result = await controller.PostChangePassword( + newChangePasswordRequest: request); + + // Then + result + .Should() + .BeOfType(); + + authenticationManager + .Verify(expression: manager => + manager.ChangePasswordAsync( + username: "current.user", + oldPassword: "old-password", + newPassword: "new-password"), + times: Times.Once); + } + + [Fact] + public async Task PostChangePassword_WhenAnonymous_ShouldReturnChallenge() + { + // Given + Mock authenticationManager = new(); + Mock currentUserManager = new(); + + AuthenticationController controller = new( + authenticationAggregationService: authenticationManager.Object, + currentUserManager: currentUserManager.Object); + + controller.ControllerContext.HttpContext = new DefaultHttpContext(); + + ChangePasswordRequest request = new() + { + OldPassword = "old-password", + NewPassword = "new-password", + ConfirmPassword = "new-password" + }; + + // When + IActionResult result = await controller.PostChangePassword( + newChangePasswordRequest: request); + + // Then + result + .Should() + .BeOfType(); + + currentUserManager.Verify( + expression: manager => manager.GetCurrentUser(), + times: Times.Never); + + authenticationManager.Verify( + expression: manager => manager.ChangePasswordAsync( + username: It.IsAny(), + oldPassword: It.IsAny(), + newPassword: It.IsAny()), + times: Times.Never); + } + + [Fact] + public async Task PostForgotPassword_ShouldQueueTokenBackedReset() + { + // Given + Mock authenticationManager = new(); + + ForgotPasswordController controller = new( + authenticationAggregationService: authenticationManager.Object); + + ForgotPasswordRequest request = new() + { + AppId = 21, + Email = "user@example.test" + }; + + // When + IActionResult result = await controller.PostForgotPassword( + newForgotPasswordRequest: request); + + // Then + result + .Should() + .BeOfType(); + + authenticationManager.Verify( + expression: manager => manager.ForgotPasswordAsync( + email: "user@example.test"), + times: Times.Once); + } + + [Fact] + public async Task PostConfirmForgotPassword_ShouldConsumeToken() + { + // Given + Mock authenticationManager = new(); + + ConfirmForgotPasswordController controller = new( + authenticationAggregationService: authenticationManager.Object); + + ConfirmForgotPasswordRequest request = new() + { + ConfirmPassword = "new-password", + NewPassword = "new-password", + SourceAppId = 21, + Token = "reset-token", + UserId = "current.user" + }; + + // When + IActionResult result = await controller.PostConfirmForgotPassword( + newConfirmForgotPasswordRequest: request); + + // Then + result + .Should() + .BeOfType(); + + authenticationManager.Verify( + expression: manager => manager.ConfirmForgotPasswordAsync( + tokenId: "reset-token", + userId: "current.user", + newPassword: "new-password", + confirmNewPassword: "new-password"), + times: Times.Once); + } + + [Fact] + public async Task PostConfirmForgotPassword_WhenTokenIsInvalid_ShouldReturnBadRequest() + { + // Given + Mock authenticationManager = new(); + + authenticationManager + .Setup(expression: manager => manager.ConfirmForgotPasswordAsync( + tokenId: "invalid-token", + userId: "current.user", + newPassword: "new-password", + confirmNewPassword: "new-password")) + .Returns(value: ValueTask.FromException( + exception: new SecurityAggregationServiceException( + innerException: new SecurityException()))); + + ConfirmForgotPasswordController controller = new( + authenticationAggregationService: authenticationManager.Object); + + ConfirmForgotPasswordRequest request = new() + { + ConfirmPassword = "new-password", + NewPassword = "new-password", + SourceAppId = 21, + Token = "invalid-token", + UserId = "current.user" + }; + + // When + IActionResult result = await controller.PostConfirmForgotPassword( + newConfirmForgotPasswordRequest: request); + + // Then + result + .Should() + .BeOfType(); + } + + [Fact] + public async Task PutMeUsesSelfServiceManager() + { + // Given + SSOUser request = new() + { + DisplayName = "New name", + Email = "new@example.com" + }; + + Mock currentUserManager = new(); + + currentUserManager + .Setup(expression: manager => manager.UpdateCurrentSSOUserAsync( + updatedUser: request)) + .ReturnsAsync(value: request); + + CurrentUserController controller = new( + currentUserAggregationService: currentUserManager.Object); + + // When + IActionResult result = await controller.PutMe(updatedUser: request); + + // Then + result + .Should() + .BeOfType(); + + currentUserManager + .Verify(expression: manager => + manager.UpdateCurrentSSOUserAsync(updatedUser: request), + times: Times.Once); + } } \ No newline at end of file diff --git a/src/cCoder.Security/Exposures/Controllers/AuthenticationController.cs b/src/cCoder.Security/Exposures/Controllers/AuthenticationController.cs index 40eff2a..074a59a 100644 --- a/src/cCoder.Security/Exposures/Controllers/AuthenticationController.cs +++ b/src/cCoder.Security/Exposures/Controllers/AuthenticationController.cs @@ -3,6 +3,7 @@ // --------------------------------------------------------------- using cCoder.Security.Models.DTOs; +using cCoder.Security.Models.Entities; using cCoder.Security.Models.Exceptions; using cCoder.Security.Services.Aggregations.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -12,7 +13,8 @@ namespace cCoder.Security.Exposures.Controllers; [Route("Api/Account")] public class AuthenticationController( - IAuthenticationManager authenticationAggregationService) + IAuthenticationManager authenticationAggregationService, + ISecurityCurrentUserManager currentUserManager) : Controller { [HttpPost("Login")] @@ -83,6 +85,68 @@ public async ValueTask PostLogout() } } + [HttpPost("ChangePassword")] + public async ValueTask PostChangePassword( + [FromBody] ChangePasswordRequest newChangePasswordRequest) + { + try + { + if (User.Identity?.IsAuthenticated != true) + { + return Challenge(); + } + + if (!ModelState.IsValid) + { + return BadRequest(modelState: ModelState); + } + + if (!string.Equals( + a: newChangePasswordRequest.NewPassword, + b: newChangePasswordRequest.ConfirmPassword, + comparisonType: StringComparison.Ordinal)) + { + return BadRequest(error: "The password confirmation does not match."); + } + + SSOUser currentUser = currentUserManager.GetCurrentUser(); + + await authenticationAggregationService.ChangePasswordAsync( + username: currentUser.Id, + oldPassword: newChangePasswordRequest.OldPassword, + newPassword: newChangePasswordRequest.NewPassword); + + return Ok(); + } + catch (SecurityAggregationAuthenticationException) + { + return Challenge(); + } + catch (SecurityAggregationValidationException) + { + return BadRequest(error: "The password change is invalid."); + } + catch (SecurityAggregationServiceException exception) + when (ContainsSecurityException(exception: exception)) + { + return StatusCode( + statusCode: StatusCodes.Status401Unauthorized, + value: "The supplied credentials are invalid."); + } + catch (SecurityAggregationDependencyException) + { + return StatusCode( + statusCode: StatusCodes.Status503ServiceUnavailable, + value: "The security service is unavailable."); + } + catch (Exception) + { + return StatusCode( + statusCode: StatusCodes.Status500InternalServerError, + value: "The security operation failed."); + } + } + private static bool ContainsSecurityException(Exception exception) => exception is SecurityException || exception.InnerException is not null diff --git a/src/cCoder.Security/Exposures/Controllers/ConfirmForgotPasswordController.cs b/src/cCoder.Security/Exposures/Controllers/ConfirmForgotPasswordController.cs index b17e7bd..5212816 100644 --- a/src/cCoder.Security/Exposures/Controllers/ConfirmForgotPasswordController.cs +++ b/src/cCoder.Security/Exposures/Controllers/ConfirmForgotPasswordController.cs @@ -6,6 +6,7 @@ using cCoder.Security.Models.Exceptions; using cCoder.Security.Services.Aggregations.Interfaces; using Microsoft.AspNetCore.Mvc; +using System.Security; namespace cCoder.Security.Exposures.Controllers; @@ -37,6 +38,11 @@ await authenticationAggregationService.ConfirmForgotPasswordAsync( { return BadRequest(error: "The password reset request is invalid."); } + catch (SecurityAggregationServiceException exception) + when (ContainsSecurityException(exception: exception)) + { + return BadRequest(error: "The password reset request is invalid."); + } catch (SecurityAggregationDependencyException) { return Problem(statusCode: StatusCodes.Status503ServiceUnavailable); @@ -48,4 +54,9 @@ await authenticationAggregationService.ConfirmForgotPasswordAsync( value: "The security operation failed."); } } + + private static bool ContainsSecurityException(Exception exception) => + exception is SecurityException + || exception.InnerException is not null + && ContainsSecurityException(exception: exception.InnerException); } \ No newline at end of file diff --git a/src/cCoder.Security/Exposures/Controllers/CurrentUserController.cs b/src/cCoder.Security/Exposures/Controllers/CurrentUserController.cs index e2e7131..9f380d4 100644 --- a/src/cCoder.Security/Exposures/Controllers/CurrentUserController.cs +++ b/src/cCoder.Security/Exposures/Controllers/CurrentUserController.cs @@ -3,6 +3,7 @@ // --------------------------------------------------------------- using cCoder.Security.Services.Aggregations.Interfaces; +using cCoder.Security.Models.Entities; using cCoder.Security.Models.Exceptions; using Microsoft.AspNetCore.Mvc; @@ -37,4 +38,39 @@ public IActionResult GetMe() value: "The security operation failed."); } } + + [HttpPut("Me")] + public async ValueTask PutMe([FromBody] SSOUser updatedUser) + { + try + { + if (!ModelState.IsValid) + { + return BadRequest(modelState: ModelState); + } + + return Ok(value: await currentUserAggregationService + .UpdateCurrentSSOUserAsync(updatedUser: updatedUser)); + } + catch (SecurityAggregationAuthenticationException) + { + return Challenge(); + } + catch (SecurityAggregationValidationException) + { + return BadRequest(error: "The profile update is invalid."); + } + catch (SecurityAggregationDependencyException) + { + return StatusCode( + statusCode: StatusCodes.Status503ServiceUnavailable, + value: "The security service is unavailable."); + } + catch (Exception) + { + return StatusCode( + statusCode: StatusCodes.Status500InternalServerError, + value: "The security operation failed."); + } + } } \ No newline at end of file diff --git a/src/cCoder.Security/Exposures/ISecurityCurrentUserManager.cs b/src/cCoder.Security/Exposures/ISecurityCurrentUserManager.cs index b209dc7..fd03fe6 100644 --- a/src/cCoder.Security/Exposures/ISecurityCurrentUserManager.cs +++ b/src/cCoder.Security/Exposures/ISecurityCurrentUserManager.cs @@ -9,4 +9,6 @@ namespace cCoder.Security.Exposures; public interface ISecurityCurrentUserManager { SSOUser GetCurrentUser(); + + ValueTask UpdateCurrentSSOUserAsync(SSOUser updatedUser); } \ No newline at end of file diff --git a/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.Exceptions.cs b/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.Exceptions.cs index f4ce491..0836290 100644 --- a/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.Exceptions.cs +++ b/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.Exceptions.cs @@ -36,4 +36,38 @@ private static T TryCatch(Func operation) throw new SecurityAggregationServiceException(innerException: innerException); } } + + private static async ValueTask TryCatch( + Func> operation) + { + try + { + return await operation(); + } + catch (SecurityAuthenticationException innerException) + { + throw new SecurityAggregationAuthenticationException( + innerException: innerException); + } + catch (ArgumentException innerException) + { + throw new SecurityAggregationValidationException( + innerException: innerException); + } + catch (SecurityProcessingValidationException innerException) + { + throw new SecurityAggregationValidationException( + innerException: innerException); + } + catch (SecurityProcessingDependencyException innerException) + { + throw new SecurityAggregationDependencyException( + innerException: innerException); + } + catch (Exception innerException) + { + throw new SecurityAggregationServiceException( + innerException: innerException); + } + } } \ No newline at end of file diff --git a/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.Validations.cs b/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.Validations.cs index 5a190f9..b59f324 100644 --- a/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.Validations.cs +++ b/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.Validations.cs @@ -2,6 +2,7 @@ // Copyright (c) Paul.Ward@ccoder.co.uk // --------------------------------------------------------------- using cCoder.Security.Models.Configurations; +using cCoder.Security.Models.Entities; using cCoder.Security.Models.Exceptions; namespace cCoder.Security.Services.Aggregations; @@ -26,4 +27,34 @@ private static void ValidateCurrentUserOnGet(ISSOAuthInfo authInfo) message: "The supplied authentication credentials are invalid."); } } + + private static void ValidateCurrentUserOnUpdate( + SSOUser updatedUser, + ISSOAuthInfo authInfo) + { + Validate(inputs: [updatedUser, authInfo]); + + if (authInfo.AuthenticationFailed) + { + throw new SecurityAuthenticationException( + message: "The supplied authentication credentials are invalid."); + } + + if (string.IsNullOrWhiteSpace(value: authInfo.SSOUserId) + || string.Equals( + a: authInfo.SSOUserId, + b: "Guest", + comparisonType: StringComparison.OrdinalIgnoreCase)) + { + throw new SecurityAuthenticationException( + message: "An authenticated user is required."); + } + + if (string.IsNullOrWhiteSpace(value: updatedUser.DisplayName) + || string.IsNullOrWhiteSpace(value: updatedUser.Email)) + { + throw new ArgumentException( + message: "Display name and email are required."); + } + } } \ No newline at end of file diff --git a/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.cs b/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.cs index 56f2a8d..a93511e 100644 --- a/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.cs +++ b/src/cCoder.Security/Services/Aggregations/CurrentUserAggregationService.cs @@ -22,6 +22,25 @@ public SSOUser GetCurrentUser() => return Sanitize(user: ssoUserProcessingService.Me()); }); + public ValueTask UpdateCurrentSSOUserAsync(SSOUser updatedUser) => + TryCatch(operation: async () => + { + ValidateCurrentUserOnUpdate( + updatedUser: updatedUser, + authInfo: authInfo); + + SSOUser currentUser = ssoUserProcessingService.Me(); + + currentUser.DisplayName = updatedUser.DisplayName; + currentUser.Email = updatedUser.Email; + currentUser.PhoneNumber = updatedUser.PhoneNumber; + + SSOUser result = await ssoUserProcessingService + .UpdateSSOUserAsync(item: currentUser); + + return Sanitize(user: result); + }); + private static SSOUser Sanitize(SSOUser user) => user is null ? null diff --git a/src/cCoder.Security/project.stxjson b/src/cCoder.Security/project.stxjson index 48b7fc4..37490db 100644 --- a/src/cCoder.Security/project.stxjson +++ b/src/cCoder.Security/project.stxjson @@ -3693,7 +3693,7 @@ { "Name": "cCoder.Security.Exposures.Controllers.AuthenticationController", "StandardElementType": "HttpExposure", - "LineNumber": 13, + "LineNumber": 14, "IsPublic": true, "Kind": "Class", "BaseType": { @@ -3709,10 +3709,130 @@ "Interfaces": [], "Properties": [], "Methods": [ + { + "Id": "cCoder.Security.Exposures.Controllers.AuthenticationController.PostChangePassword(cCoder.Security.Models.DTOs.ChangePasswordRequest)", + "Name": "PostChangePassword", + "LineNumber": 89, + "Inputs": [ + { + "Name": "newChangePasswordRequest", + "Type": "cCoder.Security.Models.DTOs.ChangePasswordRequest" + } + ], + "ReturnType": "System.Threading.Tasks.ValueTask\u003CIActionResult\u003E", + "Implements": [], + "Calls": [ + { + "TypeName": "System.String", + "MethodName": "Equals", + "MethodId": "System.String.Equals(System.String?,System.String?,System.StringComparison)", + "StandardElementType": "Dependency", + "IsDependencyBoundary": true + }, + { + "TypeName": "cCoder.Security.Exposures.IAuthenticationManager", + "MethodName": "ChangePasswordAsync", + "MethodId": "cCoder.Security.Exposures.IAuthenticationManager.ChangePasswordAsync(System.String,System.String,System.String)", + "StandardElementType": "Exposure", + "IsDependencyBoundary": false + }, + { + "TypeName": "cCoder.Security.Exposures.ISecurityCurrentUserManager", + "MethodName": "GetCurrentUser", + "MethodId": "cCoder.Security.Exposures.ISecurityCurrentUserManager.GetCurrentUser()", + "StandardElementType": "Exposure", + "IsDependencyBoundary": false + } + ], + "PossibleExceptionTypes": [ + "Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException", + "System.ArgumentNullException", + "System.ComponentModel.DataAnnotations.ValidationException", + "System.FormatException", + "System.Security.SecurityException", + "cCoder.Security.Models.Exceptions.SecurityAggregationAuthenticationException", + "cCoder.Security.Models.Exceptions.SecurityAggregationDependencyException", + "cCoder.Security.Models.Exceptions.SecurityAggregationServiceException", + "cCoder.Security.Models.Exceptions.SecurityAggregationValidationException", + "cCoder.Security.Models.Exceptions.SecurityAuthenticationException", + "cCoder.Security.Models.Exceptions.SecurityDependencyException", + "cCoder.Security.Models.Exceptions.SecurityProcessingDependencyException", + "cCoder.Security.Models.Exceptions.SecurityProcessingServiceException", + "cCoder.Security.Models.Exceptions.SecurityProcessingValidationException", + "cCoder.Security.Models.Exceptions.SecurityServiceException", + "cCoder.Security.Models.Exceptions.SecurityValidationException" + ], + "IncomingExceptionTypes": [ + "cCoder.Security.Models.Exceptions.SecurityAggregationAuthenticationException", + "cCoder.Security.Models.Exceptions.SecurityAggregationServiceException" + ], + "ThrowsExceptionTypes": [], + "HttpMethods": [ + "POST" + ], + "HttpResponses": [ + { + "StatusCode": 200, + "ResultMethod": "Ok", + "ExceptionType": "", + "IsExceptionPath": false, + "IsNullPath": false, + "HasBody": false, + "ExposesExceptionDetails": false, + "LogsException": false + }, + { + "StatusCode": 400, + "ResultMethod": "BadRequest", + "ExceptionType": "", + "IsExceptionPath": false, + "IsNullPath": false, + "HasBody": true, + "ExposesExceptionDetails": false, + "LogsException": false + }, + { + "StatusCode": 400, + "ResultMethod": "BadRequest", + "ExceptionType": "cCoder.Security.Models.Exceptions.SecurityAggregationValidationException", + "IsExceptionPath": true, + "IsNullPath": false, + "HasBody": true, + "ExposesExceptionDetails": false, + "LogsException": false + }, + { + "StatusCode": 401, + "ResultMethod": "Challenge", + "ExceptionType": "", + "IsExceptionPath": false, + "IsNullPath": false, + "HasBody": false, + "ExposesExceptionDetails": false, + "LogsException": false + }, + { + "StatusCode": 401, + "ResultMethod": "Challenge", + "ExceptionType": "cCoder.Security.Models.Exceptions.SecurityAggregationAuthenticationException", + "IsExceptionPath": true, + "IsNullPath": false, + "HasBody": false, + "ExposesExceptionDetails": false, + "LogsException": false + } + ], + "IsHttpRequestHandler": true, + "IsODataControllerAction": false, + "HasFromBodyParameter": false, + "HasKeyParameter": false, + "HandlesNullWithNotFound": false, + "HasTryCatch": true + }, { "Id": "cCoder.Security.Exposures.Controllers.AuthenticationController.PostLogin(cCoder.Security.Models.DTOs.Auth)", "Name": "PostLogin", - "LineNumber": 19, + "LineNumber": 21, "Inputs": [ { "Name": "newAuth", @@ -3785,7 +3905,7 @@ { "Id": "cCoder.Security.Exposures.Controllers.AuthenticationController.PostLogout()", "Name": "PostLogout", - "LineNumber": 60, + "LineNumber": 62, "Inputs": [], "ReturnType": "System.Threading.Tasks.ValueTask\u003CIActionResult\u003E", "Implements": [], @@ -3852,7 +3972,7 @@ { "Name": "cCoder.Security.Exposures.Controllers.ConfirmForgotPasswordController", "StandardElementType": "HttpExposure", - "LineNumber": 12, + "LineNumber": 13, "IsPublic": true, "Kind": "Class", "BaseType": { @@ -3871,7 +3991,7 @@ { "Id": "cCoder.Security.Exposures.Controllers.ConfirmForgotPasswordController.PostConfirmForgotPassword(cCoder.Security.Models.DTOs.ConfirmForgotPasswordRequest)", "Name": "PostConfirmForgotPassword", - "LineNumber": 18, + "LineNumber": 19, "Inputs": [ { "Name": "newConfirmForgotPasswordRequest", @@ -3941,6 +4061,16 @@ "HasBody": true, "ExposesExceptionDetails": false, "LogsException": false + }, + { + "StatusCode": 400, + "ResultMethod": "BadRequest", + "ExceptionType": "cCoder.Security.Models.Exceptions.SecurityAggregationServiceException", + "IsExceptionPath": true, + "IsNullPath": false, + "HasBody": true, + "ExposesExceptionDetails": false, + "LogsException": false } ], "IsHttpRequestHandler": true, @@ -3955,7 +4085,7 @@ { "Name": "cCoder.Security.Exposures.Controllers.CurrentUserController", "StandardElementType": "HttpExposure", - "LineNumber": 11, + "LineNumber": 12, "IsPublic": true, "Kind": "Class", "BaseType": { @@ -3974,7 +4104,7 @@ { "Id": "cCoder.Security.Exposures.Controllers.CurrentUserController.GetMe()", "Name": "GetMe", - "LineNumber": 17, + "LineNumber": 18, "Inputs": [], "ReturnType": "IActionResult", "Implements": [], @@ -4037,6 +4167,102 @@ "HasKeyParameter": false, "HandlesNullWithNotFound": false, "HasTryCatch": true + }, + { + "Id": "cCoder.Security.Exposures.Controllers.CurrentUserController.PutMe(cCoder.Security.Models.Entities.SSOUser)", + "Name": "PutMe", + "LineNumber": 43, + "Inputs": [ + { + "Name": "updatedUser", + "Type": "cCoder.Security.Models.Entities.SSOUser" + } + ], + "ReturnType": "System.Threading.Tasks.ValueTask\u003CIActionResult\u003E", + "Implements": [], + "Calls": [ + { + "TypeName": "cCoder.Security.Exposures.ISecurityCurrentUserManager", + "MethodName": "UpdateCurrentSSOUserAsync", + "MethodId": "cCoder.Security.Exposures.ISecurityCurrentUserManager.UpdateCurrentSSOUserAsync(cCoder.Security.Models.Entities.SSOUser)", + "StandardElementType": "Exposure", + "IsDependencyBoundary": false + } + ], + "PossibleExceptionTypes": [ + "Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException", + "System.ArgumentException", + "System.ArgumentNullException", + "System.ComponentModel.DataAnnotations.ValidationException", + "cCoder.Security.Models.Exceptions.SecurityAggregationAuthenticationException", + "cCoder.Security.Models.Exceptions.SecurityAggregationDependencyException", + "cCoder.Security.Models.Exceptions.SecurityAggregationServiceException", + "cCoder.Security.Models.Exceptions.SecurityAggregationValidationException", + "cCoder.Security.Models.Exceptions.SecurityAuthenticationException", + "cCoder.Security.Models.Exceptions.SecurityDependencyException", + "cCoder.Security.Models.Exceptions.SecurityProcessingDependencyException", + "cCoder.Security.Models.Exceptions.SecurityProcessingServiceException", + "cCoder.Security.Models.Exceptions.SecurityProcessingValidationException", + "cCoder.Security.Models.Exceptions.SecurityServiceException", + "cCoder.Security.Models.Exceptions.SecurityValidationException" + ], + "IncomingExceptionTypes": [ + "cCoder.Security.Models.Exceptions.SecurityAggregationAuthenticationException", + "cCoder.Security.Models.Exceptions.SecurityAggregationServiceException", + "cCoder.Security.Models.Exceptions.SecurityAggregationValidationException" + ], + "ThrowsExceptionTypes": [], + "HttpMethods": [ + "PUT" + ], + "HttpResponses": [ + { + "StatusCode": 200, + "ResultMethod": "Ok", + "ExceptionType": "", + "IsExceptionPath": false, + "IsNullPath": false, + "HasBody": true, + "ExposesExceptionDetails": false, + "LogsException": false + }, + { + "StatusCode": 400, + "ResultMethod": "BadRequest", + "ExceptionType": "", + "IsExceptionPath": false, + "IsNullPath": false, + "HasBody": true, + "ExposesExceptionDetails": false, + "LogsException": false + }, + { + "StatusCode": 400, + "ResultMethod": "BadRequest", + "ExceptionType": "cCoder.Security.Models.Exceptions.SecurityAggregationValidationException", + "IsExceptionPath": true, + "IsNullPath": false, + "HasBody": true, + "ExposesExceptionDetails": false, + "LogsException": false + }, + { + "StatusCode": 401, + "ResultMethod": "Challenge", + "ExceptionType": "cCoder.Security.Models.Exceptions.SecurityAggregationAuthenticationException", + "IsExceptionPath": true, + "IsNullPath": false, + "HasBody": false, + "ExposesExceptionDetails": false, + "LogsException": false + } + ], + "IsHttpRequestHandler": true, + "IsODataControllerAction": false, + "HasFromBodyParameter": false, + "HasKeyParameter": false, + "HandlesNullWithNotFound": false, + "HasTryCatch": true } ] }, @@ -7978,6 +8204,74 @@ "HasKeyParameter": false, "HandlesNullWithNotFound": false, "HasTryCatch": false + }, + { + "Id": "cCoder.Security.Services.Aggregations.CurrentUserAggregationService.UpdateCurrentSSOUserAsync(cCoder.Security.Models.Entities.SSOUser)", + "Name": "UpdateCurrentSSOUserAsync", + "LineNumber": 25, + "Inputs": [ + { + "Name": "updatedUser", + "Type": "cCoder.Security.Models.Entities.SSOUser" + } + ], + "ReturnType": "System.Threading.Tasks.ValueTask\u003CcCoder.Security.Models.Entities.SSOUser\u003E", + "Implements": [ + "cCoder.Security.Exposures.ISecurityCurrentUserManager.UpdateCurrentSSOUserAsync(cCoder.Security.Models.Entities.SSOUser)" + ], + "Calls": [ + { + "TypeName": "cCoder.Security.Services.Processings.Interfaces.ISSOUserProcessingService", + "MethodName": "Me", + "MethodId": "cCoder.Security.Services.Processings.Interfaces.ISSOUserProcessingService.Me()", + "StandardElementType": "ProcessingService", + "IsDependencyBoundary": false + }, + { + "TypeName": "cCoder.Security.Services.Processings.Interfaces.ISSOUserProcessingService", + "MethodName": "UpdateSSOUserAsync", + "MethodId": "cCoder.Security.Services.Processings.Interfaces.ISSOUserProcessingService.UpdateSSOUserAsync(cCoder.Security.Models.Entities.SSOUser)", + "StandardElementType": "ProcessingService", + "IsDependencyBoundary": false + } + ], + "PossibleExceptionTypes": [ + "Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException", + "System.ArgumentException", + "System.ArgumentNullException", + "System.ComponentModel.DataAnnotations.ValidationException", + "cCoder.Security.Models.Exceptions.SecurityAggregationAuthenticationException", + "cCoder.Security.Models.Exceptions.SecurityAggregationDependencyException", + "cCoder.Security.Models.Exceptions.SecurityAggregationServiceException", + "cCoder.Security.Models.Exceptions.SecurityAggregationValidationException", + "cCoder.Security.Models.Exceptions.SecurityAuthenticationException", + "cCoder.Security.Models.Exceptions.SecurityDependencyException", + "cCoder.Security.Models.Exceptions.SecurityProcessingDependencyException", + "cCoder.Security.Models.Exceptions.SecurityProcessingServiceException", + "cCoder.Security.Models.Exceptions.SecurityProcessingValidationException", + "cCoder.Security.Models.Exceptions.SecurityServiceException", + "cCoder.Security.Models.Exceptions.SecurityValidationException" + ], + "IncomingExceptionTypes": [ + "System.ArgumentException", + "System.ArgumentNullException", + "cCoder.Security.Models.Exceptions.SecurityAggregationServiceException", + "cCoder.Security.Models.Exceptions.SecurityAuthenticationException", + "cCoder.Security.Models.Exceptions.SecurityProcessingServiceException" + ], + "ThrowsExceptionTypes": [ + "cCoder.Security.Models.Exceptions.SecurityAggregationAuthenticationException", + "cCoder.Security.Models.Exceptions.SecurityAggregationServiceException", + "cCoder.Security.Models.Exceptions.SecurityAggregationValidationException" + ], + "HttpMethods": [], + "HttpResponses": [], + "IsHttpRequestHandler": false, + "IsODataControllerAction": false, + "HasFromBodyParameter": false, + "HasKeyParameter": false, + "HandlesNullWithNotFound": false, + "HasTryCatch": false } ] }, @@ -17516,6 +17810,31 @@ "HasKeyParameter": false, "HandlesNullWithNotFound": false, "HasTryCatch": false + }, + { + "Id": "cCoder.Security.Exposures.ISecurityCurrentUserManager.UpdateCurrentSSOUserAsync(cCoder.Security.Models.Entities.SSOUser)", + "Name": "UpdateCurrentSSOUserAsync", + "LineNumber": 13, + "Inputs": [ + { + "Name": "updatedUser", + "Type": "cCoder.Security.Models.Entities.SSOUser" + } + ], + "ReturnType": "System.Threading.Tasks.ValueTask\u003CcCoder.Security.Models.Entities.SSOUser\u003E", + "Implements": [], + "Calls": [], + "PossibleExceptionTypes": [], + "IncomingExceptionTypes": [], + "ThrowsExceptionTypes": [], + "HttpMethods": [], + "HttpResponses": [], + "IsHttpRequestHandler": false, + "IsODataControllerAction": false, + "HasFromBodyParameter": false, + "HasKeyParameter": false, + "HandlesNullWithNotFound": false, + "HasTryCatch": false } ] }, @@ -20523,6 +20842,10 @@ "FromType": "cCoder.Security.Exposures.Controllers.AuthenticationController", "ToType": "cCoder.Security.Services.Aggregations.AuthenticationAggregationService" }, + { + "FromType": "cCoder.Security.Exposures.Controllers.AuthenticationController", + "ToType": "cCoder.Security.Services.Aggregations.CurrentUserAggregationService" + }, { "FromType": "cCoder.Security.Exposures.Controllers.ConfirmForgotPasswordController", "ToType": "cCoder.Security.Services.Aggregations.AuthenticationAggregationService" @@ -20869,39 +21192,74 @@ } ], "AnalysisItems": [ + { + "Code": "STXAPI001", + "Description": "An API controller must have exactly one business dependency.", + "Severity": "Warning", + "Type": "cCoder.Security.Exposures.Controllers.AuthenticationController", + "LineNumber": 14 + }, + { + "Code": "STXAPI002", + "Description": "An API controller must expose a single model contract.", + "Severity": "Warning", + "Type": "cCoder.Security.Exposures.Controllers.AuthenticationController", + "LineNumber": 14 + }, { "Code": "STXAPI005", "Description": "Every public HTTP handler must map success and caught failure paths to 2xx, 4xx, or 5xx responses.", "Severity": "Warning", "Type": "cCoder.Security.Exposures.Controllers.AuthenticationController", - "LineNumber": 19 + "LineNumber": 21 }, { "Code": "STXAPI005", "Description": "Every public HTTP handler must map success and caught failure paths to 2xx, 4xx, or 5xx responses.", "Severity": "Warning", "Type": "cCoder.Security.Exposures.Controllers.AuthenticationController", - "LineNumber": 60 + "LineNumber": 62 }, { "Code": "STXAPI006", "Description": "An HTTP failure response must log the caught exception before returning it.", "Severity": "Warning", "Type": "cCoder.Security.Exposures.Controllers.AuthenticationController", - "LineNumber": 60 + "LineNumber": 62 }, { "Code": "STXAPI005", "Description": "Every public HTTP handler must map success and caught failure paths to 2xx, 4xx, or 5xx responses.", "Severity": "Warning", + "Type": "cCoder.Security.Exposures.Controllers.AuthenticationController", + "LineNumber": 89 + }, + { + "Code": "STXAPI006", + "Description": "An HTTP failure response must log the caught exception before returning it.", + "Severity": "Warning", + "Type": "cCoder.Security.Exposures.Controllers.AuthenticationController", + "LineNumber": 89 + }, + { + "Code": "STXAPI006", + "Description": "An HTTP failure response must log the caught exception before returning it.", + "Severity": "Warning", "Type": "cCoder.Security.Exposures.Controllers.ConfirmForgotPasswordController", + "LineNumber": 19 + }, + { + "Code": "STXAPI005", + "Description": "Every public HTTP handler must map success and caught failure paths to 2xx, 4xx, or 5xx responses.", + "Severity": "Warning", + "Type": "cCoder.Security.Exposures.Controllers.CurrentUserController", "LineNumber": 18 }, { "Code": "STXAPI006", "Description": "An HTTP failure response must log the caught exception before returning it.", "Severity": "Warning", - "Type": "cCoder.Security.Exposures.Controllers.ConfirmForgotPasswordController", + "Type": "cCoder.Security.Exposures.Controllers.CurrentUserController", "LineNumber": 18 }, { @@ -20909,14 +21267,14 @@ "Description": "Every public HTTP handler must map success and caught failure paths to 2xx, 4xx, or 5xx responses.", "Severity": "Warning", "Type": "cCoder.Security.Exposures.Controllers.CurrentUserController", - "LineNumber": 17 + "LineNumber": 43 }, { "Code": "STXAPI006", "Description": "An HTTP failure response must log the caught exception before returning it.", "Severity": "Warning", "Type": "cCoder.Security.Exposures.Controllers.CurrentUserController", - "LineNumber": 17 + "LineNumber": 43 }, { "Code": "STXAPI005",