From c380bebdd69fc2d318911117189d1fedceca0665 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sat, 22 Aug 2026 17:24:53 -0400 Subject: [PATCH] Add self-service account management commands (delete, update username/email/password/profile) --- .../Controllers/ProtectedController.cs | 6 +- web/backend/API/API.Core/Program.cs | 33 +++- web/backend/API/API.Specs/Steps/AuthSteps.cs | 28 +--- .../Commands/DeleteAccountHandlerTests.cs | 74 +++++++++ .../Commands/UpdateEmailHandlerTests.cs | 96 ++++++++++++ .../Commands/UpdatePasswordHandlerTests.cs | 83 ++++++++++ .../Commands/UpdateProfileHandlerTests.cs | 67 +++++++++ .../Commands/UpdateUsernameHandlerTests.cs | 84 +++++++++++ .../DeleteAccount/DeleteAccountCommand.cs | 10 ++ .../DeleteAccount/DeleteAccountHandler.cs | 33 ++++ .../RefreshToken/RefreshTokenCommand.cs | 4 +- .../RegisterUser/RegisterUserHandler.cs | 7 +- .../UpdateEmail/UpdateEmailCommand.cs | 14 ++ .../UpdateEmail/UpdateEmailHandler.cs | 39 +++++ .../UpdateEmail/UpdateEmailValidator.cs | 18 +++ .../UpdatePassword/UpdatePasswordCommand.cs | 12 ++ .../UpdatePassword/UpdatePasswordHandler.cs | 42 ++++++ .../UpdatePassword/UpdatePasswordValidator.cs | 28 ++++ .../UpdateProfile/UpdateProfileCommand.cs | 19 +++ .../UpdateProfile/UpdateProfileHandler.cs | 38 +++++ .../UpdateProfile/UpdateProfileValidator.cs | 28 ++++ .../UpdateUsername/UpdateUsernameCommand.cs | 12 ++ .../UpdateUsername/UpdateUsernameHandler.cs | 34 +++++ .../UpdateUsername/UpdateUsernameValidator.cs | 20 +++ .../Controllers/AuthController.cs | 136 ++++++++++++++++- .../Features.Auth/Dtos/AuthPayloads.cs | 45 ++++++ .../Features.Auth/Identity/ApplicationUser.cs | 2 + .../Identity/Argon2PasswordHasher.cs | 8 + .../Features.Auth/Identity/DapperUserStore.cs | 142 +++++++++++++++++- .../Identity/UserAccountExtensions.cs | 23 +++ 30 files changed, 1143 insertions(+), 42 deletions(-) create mode 100644 web/backend/Features/Features.Auth.Tests/Commands/DeleteAccountHandlerTests.cs create mode 100644 web/backend/Features/Features.Auth.Tests/Commands/UpdateEmailHandlerTests.cs create mode 100644 web/backend/Features/Features.Auth.Tests/Commands/UpdatePasswordHandlerTests.cs create mode 100644 web/backend/Features/Features.Auth.Tests/Commands/UpdateProfileHandlerTests.cs create mode 100644 web/backend/Features/Features.Auth.Tests/Commands/UpdateUsernameHandlerTests.cs create mode 100644 web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountCommand.cs create mode 100644 web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountHandler.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailCommand.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailHandler.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailValidator.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordCommand.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordHandler.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordValidator.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileCommand.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileHandler.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileValidator.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameCommand.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameHandler.cs create mode 100644 web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameValidator.cs create mode 100644 web/backend/Features/Features.Auth/Identity/UserAccountExtensions.cs diff --git a/web/backend/API/API.Core/Controllers/ProtectedController.cs b/web/backend/API/API.Core/Controllers/ProtectedController.cs index a88e8e0f..36964b99 100644 --- a/web/backend/API/API.Core/Controllers/ProtectedController.cs +++ b/web/backend/API/API.Core/Controllers/ProtectedController.cs @@ -1,7 +1,7 @@ -using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Shared.Contracts; +using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; namespace API.Core.Controllers; @@ -20,8 +20,8 @@ public class ProtectedController : ControllerBase [HttpGet] public ActionResult> Get() { - string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - string? username = User.FindFirst(ClaimTypes.Name)?.Value; + string? userId = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; + string? username = User.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value; return Ok( new ResponseBody diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index 5e9b85d2..c2a266c1 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -12,6 +12,7 @@ using FluentValidation; using FluentValidation.AspNetCore; using Infrastructure.Jwt; using Infrastructure.Sql.DependencyInjection; +using Microsoft.OpenApi.Models; using Shared.Application.Behaviors; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -27,7 +28,37 @@ builder .AddApplicationPart(typeof(AuthController).Assembly); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(options => +{ + options.AddSecurityDefinition( + "Bearer", + new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Enter a JWT access token.", + } + ); + options.AddSecurityRequirement( + new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer", + }, + }, + [] + }, + } + ); +}); builder.Services.AddOpenApi(); // Add FluentValidation diff --git a/web/backend/API/API.Specs/Steps/AuthSteps.cs b/web/backend/API/API.Specs/Steps/AuthSteps.cs index 9aa9ddb9..24f8ef62 100644 --- a/web/backend/API/API.Specs/Steps/AuthSteps.cs +++ b/web/backend/API/API.Specs/Steps/AuthSteps.cs @@ -602,12 +602,9 @@ public class AuthSteps(ScenarioContext scenario) string? token = scenario.TryGetValue("refreshToken", out string? t) ? t : "valid-refresh-token"; - string body = JsonSerializer.Serialize(new { refreshToken = token }); - HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh") - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }; + HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh"); + requestMessage.Headers.Add("X-Refresh-Token", token); HttpResponseMessage response = await client.SendAsync(requestMessage); string responseBody = await response.Content.ReadAsStringAsync(); @@ -619,12 +616,9 @@ public class AuthSteps(ScenarioContext scenario) public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken() { HttpClient client = GetClient(); - string body = JsonSerializer.Serialize(new { refreshToken = "invalid-refresh-token" }); - HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh") - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }; + HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh"); + requestMessage.Headers.Add("X-Refresh-Token", "invalid-refresh-token"); HttpResponseMessage response = await client.SendAsync(requestMessage); string responseBody = await response.Content.ReadAsStringAsync(); @@ -636,13 +630,9 @@ public class AuthSteps(ScenarioContext scenario) public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken() { HttpClient client = GetClient(); - // Use an expired token - string body = JsonSerializer.Serialize(new { refreshToken = "expired-refresh-token" }); - HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh") - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }; + HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh"); + requestMessage.Headers.Add("X-Refresh-Token", "expired-refresh-token"); HttpResponseMessage response = await client.SendAsync(requestMessage); string responseBody = await response.Content.ReadAsStringAsync(); @@ -654,12 +644,8 @@ public class AuthSteps(ScenarioContext scenario) public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken() { HttpClient client = GetClient(); - string body = JsonSerializer.Serialize(new { }); - HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh") - { - Content = new StringContent(body, Encoding.UTF8, "application/json"), - }; + HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh"); HttpResponseMessage response = await client.SendAsync(requestMessage); string responseBody = await response.Content.ReadAsStringAsync(); diff --git a/web/backend/Features/Features.Auth.Tests/Commands/DeleteAccountHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/DeleteAccountHandlerTests.cs new file mode 100644 index 00000000..ea7a7cc6 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Commands/DeleteAccountHandlerTests.cs @@ -0,0 +1,74 @@ +using Domain.Exceptions; +using Features.Auth.Commands.DeleteAccount; +using Features.Auth.Identity; +using Features.Auth.Tests.TestSupport; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Moq; + +namespace Features.Auth.Tests.Commands; + +public class DeleteAccountHandlerTests +{ + private readonly DeleteAccountHandler _handler; + private readonly Mock> _userManagerMock; + + public DeleteAccountHandlerTests() + { + _userManagerMock = UserManagerMockFactory.Create(); + _handler = new DeleteAccountHandler(_userManagerMock.Object); + } + + [Fact] + public async Task Handle_WithExistingUser_DeletesAccount() + { + Guid userId = Guid.NewGuid(); + ApplicationUser user = new() { Id = userId }; + + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock.Setup(x => x.DeleteAsync(user)).ReturnsAsync(IdentityResult.Success); + + await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None); + + _userManagerMock.Verify(x => x.DeleteAsync(user), Times.Once); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ThrowsNotFoundException() + { + Guid userId = Guid.NewGuid(); + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((ApplicationUser?)null); + + Func act = async () => + await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_WithDependentRecords_ThrowsConflictException() + { + Guid userId = Guid.NewGuid(); + ApplicationUser user = new() { Id = userId }; + + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock + .Setup(x => x.DeleteAsync(user)) + .ReturnsAsync( + IdentityResult.Failed( + new IdentityError + { + Code = "AccountHasDependentRecords", + Description = "still referenced", + } + ) + ); + + Func act = async () => + await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None); + + await act.Should().ThrowAsync(); + } +} diff --git a/web/backend/Features/Features.Auth.Tests/Commands/UpdateEmailHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/UpdateEmailHandlerTests.cs new file mode 100644 index 00000000..13123fc2 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Commands/UpdateEmailHandlerTests.cs @@ -0,0 +1,96 @@ +using Domain.Exceptions; +using Features.Auth.Commands.UpdateEmail; +using Features.Auth.Dtos; +using Features.Auth.Identity; +using Features.Auth.Tests.TestSupport; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Moq; + +namespace Features.Auth.Tests.Commands; + +public class UpdateEmailHandlerTests +{ + private readonly UpdateEmailHandler _handler; + private readonly Mock> _userManagerMock; + + public UpdateEmailHandlerTests() + { + _userManagerMock = UserManagerMockFactory.Create(); + _handler = new UpdateEmailHandler(_userManagerMock.Object); + } + + [Fact] + public async Task Handle_WithValidRequest_UpdatesEmailAndResetsConfirmation() + { + Guid userId = Guid.NewGuid(); + ApplicationUser user = new() + { + Id = userId, + Email = "old@example.com", + EmailConfirmed = true, + }; + + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock + .Setup(x => x.SetEmailAsync(user, "new@example.com")) + .Callback( + (u, e) => + { + u.Email = e; + u.EmailConfirmed = false; + } + ) + .ReturnsAsync(IdentityResult.Success); + + UpdateEmailPayload result = await _handler.Handle( + new UpdateEmailCommand(userId, "new@example.com"), + CancellationToken.None + ); + + result.UserAccountId.Should().Be(userId); + result.Email.Should().Be("new@example.com"); + result.EmailConfirmed.Should().BeFalse(); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ThrowsNotFoundException() + { + Guid userId = Guid.NewGuid(); + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((ApplicationUser?)null); + + Func> act = async () => + await _handler.Handle( + new UpdateEmailCommand(userId, "new@example.com"), + CancellationToken.None + ); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_WithDuplicateEmail_ThrowsConflictException() + { + Guid userId = Guid.NewGuid(); + ApplicationUser user = new() { Id = userId, Email = "old@example.com" }; + + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock + .Setup(x => x.SetEmailAsync(user, "taken@example.com")) + .ReturnsAsync( + IdentityResult.Failed( + new IdentityError { Code = "DuplicateEmail", Description = "taken" } + ) + ); + + Func> act = async () => + await _handler.Handle( + new UpdateEmailCommand(userId, "taken@example.com"), + CancellationToken.None + ); + + await act.Should().ThrowAsync(); + } +} diff --git a/web/backend/Features/Features.Auth.Tests/Commands/UpdatePasswordHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/UpdatePasswordHandlerTests.cs new file mode 100644 index 00000000..24da76b5 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Commands/UpdatePasswordHandlerTests.cs @@ -0,0 +1,83 @@ +using Domain.Exceptions; +using Features.Auth.Commands.UpdatePassword; +using Features.Auth.Dtos; +using Features.Auth.Identity; +using Features.Auth.Tests.TestSupport; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Moq; + +namespace Features.Auth.Tests.Commands; + +public class UpdatePasswordHandlerTests +{ + private readonly UpdatePasswordHandler _handler; + private readonly Mock> _userManagerMock; + + public UpdatePasswordHandlerTests() + { + _userManagerMock = UserManagerMockFactory.Create(); + _handler = new UpdatePasswordHandler(_userManagerMock.Object); + } + + [Fact] + public async Task Handle_WithValidRequest_ChangesPassword() + { + Guid userId = Guid.NewGuid(); + ApplicationUser user = new() { Id = userId }; + + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock + .Setup(x => x.ChangePasswordAsync(user, "OldPassword1!", "NewPassword1!")) + .ReturnsAsync(IdentityResult.Success); + + UpdatePasswordPayload result = await _handler.Handle( + new UpdatePasswordCommand(userId, "OldPassword1!", "NewPassword1!"), + CancellationToken.None + ); + + result.UserAccountId.Should().Be(userId); + result.ChangedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ThrowsNotFoundException() + { + Guid userId = Guid.NewGuid(); + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((ApplicationUser?)null); + + Func> act = async () => + await _handler.Handle( + new UpdatePasswordCommand(userId, "OldPassword1!", "NewPassword1!"), + CancellationToken.None + ); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_WithWrongCurrentPassword_ThrowsUnauthorizedException() + { + Guid userId = Guid.NewGuid(); + ApplicationUser user = new() { Id = userId }; + + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock + .Setup(x => x.ChangePasswordAsync(user, "WrongPassword1!", "NewPassword1!")) + .ReturnsAsync( + IdentityResult.Failed( + new IdentityError { Code = "PasswordMismatch", Description = "mismatch" } + ) + ); + + Func> act = async () => + await _handler.Handle( + new UpdatePasswordCommand(userId, "WrongPassword1!", "NewPassword1!"), + CancellationToken.None + ); + + await act.Should().ThrowAsync(); + } +} diff --git a/web/backend/Features/Features.Auth.Tests/Commands/UpdateProfileHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/UpdateProfileHandlerTests.cs new file mode 100644 index 00000000..6202cbf2 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Commands/UpdateProfileHandlerTests.cs @@ -0,0 +1,67 @@ +using Domain.Exceptions; +using Features.Auth.Commands.UpdateProfile; +using Features.Auth.Dtos; +using Features.Auth.Identity; +using Features.Auth.Tests.TestSupport; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Moq; + +namespace Features.Auth.Tests.Commands; + +public class UpdateProfileHandlerTests +{ + private readonly UpdateProfileHandler _handler; + private readonly Mock> _userManagerMock; + + public UpdateProfileHandlerTests() + { + _userManagerMock = UserManagerMockFactory.Create(); + _handler = new UpdateProfileHandler(_userManagerMock.Object); + } + + [Fact] + public async Task Handle_WithValidRequest_UpdatesProfileFields() + { + Guid userId = Guid.NewGuid(); + ApplicationUser user = new() + { + Id = userId, + FirstName = "Old", + LastName = "Name", + DateOfBirth = new DateTime(1990, 1, 1), + }; + DateTime newDob = new(1991, 2, 3); + + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + + UpdateProfilePayload result = await _handler.Handle( + new UpdateProfileCommand(userId, "New", "Person", newDob), + CancellationToken.None + ); + + result.UserAccountId.Should().Be(userId); + result.FirstName.Should().Be("New"); + result.LastName.Should().Be("Person"); + result.DateOfBirth.Should().Be(newDob); + _userManagerMock.Verify(x => x.UpdateAsync(user), Times.Once); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ThrowsNotFoundException() + { + Guid userId = Guid.NewGuid(); + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((ApplicationUser?)null); + + Func> act = async () => + await _handler.Handle( + new UpdateProfileCommand(userId, "New", "Person", DateTime.Today), + CancellationToken.None + ); + + await act.Should().ThrowAsync(); + } +} diff --git a/web/backend/Features/Features.Auth.Tests/Commands/UpdateUsernameHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/UpdateUsernameHandlerTests.cs new file mode 100644 index 00000000..28d37132 --- /dev/null +++ b/web/backend/Features/Features.Auth.Tests/Commands/UpdateUsernameHandlerTests.cs @@ -0,0 +1,84 @@ +using Domain.Exceptions; +using Features.Auth.Commands.UpdateUsername; +using Features.Auth.Dtos; +using Features.Auth.Identity; +using Features.Auth.Tests.TestSupport; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Moq; + +namespace Features.Auth.Tests.Commands; + +public class UpdateUsernameHandlerTests +{ + private readonly UpdateUsernameHandler _handler; + private readonly Mock> _userManagerMock; + + public UpdateUsernameHandlerTests() + { + _userManagerMock = UserManagerMockFactory.Create(); + _handler = new UpdateUsernameHandler(_userManagerMock.Object); + } + + [Fact] + public async Task Handle_WithValidRequest_UpdatesUsername() + { + Guid userId = Guid.NewGuid(); + ApplicationUser user = new() { Id = userId, UserName = "old-name" }; + + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock + .Setup(x => x.SetUserNameAsync(user, "new-name")) + .Callback((u, n) => u.UserName = n) + .ReturnsAsync(IdentityResult.Success); + + UpdateUsernamePayload result = await _handler.Handle( + new UpdateUsernameCommand(userId, "new-name"), + CancellationToken.None + ); + + result.UserAccountId.Should().Be(userId); + result.Username.Should().Be("new-name"); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ThrowsNotFoundException() + { + Guid userId = Guid.NewGuid(); + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((ApplicationUser?)null); + + Func> act = async () => + await _handler.Handle( + new UpdateUsernameCommand(userId, "new-name"), + CancellationToken.None + ); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_WithDuplicateUsername_ThrowsConflictException() + { + Guid userId = Guid.NewGuid(); + ApplicationUser user = new() { Id = userId, UserName = "old-name" }; + + _userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user); + _userManagerMock + .Setup(x => x.SetUserNameAsync(user, "taken-name")) + .ReturnsAsync( + IdentityResult.Failed( + new IdentityError { Code = "DuplicateUserName", Description = "taken" } + ) + ); + + Func> act = async () => + await _handler.Handle( + new UpdateUsernameCommand(userId, "taken-name"), + CancellationToken.None + ); + + await act.Should().ThrowAsync(); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountCommand.cs b/web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountCommand.cs new file mode 100644 index 00000000..086f665b --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountCommand.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace Features.Auth.Commands.DeleteAccount; + +/// Permanently deletes the given user account. +/// +/// The authenticated caller's own ID, extracted from the access token -- never bind this from +/// client-supplied input. +/// +public record DeleteAccountCommand(Guid UserAccountId) : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountHandler.cs b/web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountHandler.cs new file mode 100644 index 00000000..dd05b719 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/DeleteAccount/DeleteAccountHandler.cs @@ -0,0 +1,33 @@ +using Domain.Exceptions; +using Features.Auth.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Features.Auth.Commands.DeleteAccount; + +/// Handles by deleting the user via . +public class DeleteAccountHandler(UserManager userManager) + : IRequestHandler +{ + /// Thrown when the account does not exist. + /// + /// Thrown when the account cannot be deleted because other records (posts, comments, photos, + /// follows) still reference it. + /// + public async Task Handle(DeleteAccountCommand request, CancellationToken cancellationToken) + { + ApplicationUser user = + await userManager.FindByIdAsync(request.UserAccountId.ToString()) + ?? throw new NotFoundException("User account not found"); + + IdentityResult result = await userManager.DeleteAsync(user); + if (!result.Succeeded) + { + if (result.Errors.Any(e => e.Code == "AccountHasDependentRecords")) + throw new ConflictException( + "Account cannot be deleted while it still has associated posts, comments, photos, or follows." + ); + throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description))); + } + } +} diff --git a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs index d0652354..453ba306 100644 --- a/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs +++ b/web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenCommand.cs @@ -4,7 +4,7 @@ using MediatR; namespace Features.Auth.Commands.RefreshToken; /// -/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the -/// request body of POST /api/auth/refresh. +/// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the +/// X-Refresh-Token header of POST /api/auth/refresh. /// public record RefreshTokenCommand(string RefreshToken) : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs index f1c29092..bb8d4c65 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs @@ -1,3 +1,4 @@ +using Domain.Entities; using Domain.Exceptions; using Features.Auth.Dtos; using Features.Auth.Identity; @@ -28,14 +29,14 @@ public class RegisterUserHandler( CancellationToken cancellationToken ) { - ApplicationUser user = new() + ApplicationUser user = new UserAccount { - UserName = request.Username, + Username = request.Username, Email = request.Email, FirstName = request.FirstName, LastName = request.LastName, DateOfBirth = request.DateOfBirth, - }; + }.ToApplicationUser(); IdentityResult result = await userManager.CreateAsync(user, request.Password); if (!result.Succeeded) diff --git a/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailCommand.cs b/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailCommand.cs new file mode 100644 index 00000000..78c4dfb3 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailCommand.cs @@ -0,0 +1,14 @@ +using Features.Auth.Dtos; +using MediatR; + +namespace Features.Auth.Commands.UpdateEmail; + +/// +/// Changes the email address of the given user account. The account's EmailConfirmed status +/// is reset to unconfirmed as a result. +/// +/// +/// The authenticated caller's own ID, extracted from the access token -- never bind this from +/// client-supplied input. +/// +public record UpdateEmailCommand(Guid UserAccountId, string NewEmail) : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailHandler.cs b/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailHandler.cs new file mode 100644 index 00000000..1507f3a9 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailHandler.cs @@ -0,0 +1,39 @@ +using Domain.Exceptions; +using Features.Auth.Dtos; +using Features.Auth.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Features.Auth.Commands.UpdateEmail; + +/// +/// Handles via . +/// also resets EmailConfirmed to +/// , so the user must re-confirm the new address (see +/// Features.Auth.Commands.ResendConfirmationEmail). +/// +public class UpdateEmailHandler(UserManager userManager) + : IRequestHandler +{ + /// Thrown when the account does not exist. + /// Thrown when the requested email address is already in use. + public async Task Handle( + UpdateEmailCommand request, + CancellationToken cancellationToken + ) + { + ApplicationUser user = + await userManager.FindByIdAsync(request.UserAccountId.ToString()) + ?? throw new NotFoundException("User account not found"); + + IdentityResult result = await userManager.SetEmailAsync(user, request.NewEmail); + if (!result.Succeeded) + { + if (result.Errors.Any(e => e.Code == "DuplicateEmail")) + throw new ConflictException("Email address already exists"); + throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + return new UpdateEmailPayload(user.Id, user.Email, user.EmailConfirmed); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailValidator.cs b/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailValidator.cs new file mode 100644 index 00000000..3ddb80fc --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdateEmail/UpdateEmailValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace Features.Auth.Commands.UpdateEmail; + +/// Validates , mirroring the email rules in RegisterUserValidator. +public class UpdateEmailValidator : AbstractValidator +{ + public UpdateEmailValidator() + { + RuleFor(x => x.NewEmail) + .NotEmpty() + .WithMessage("Email is required") + .EmailAddress() + .WithMessage("Invalid email format") + .MaximumLength(128) + .WithMessage("Email cannot exceed 128 characters"); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordCommand.cs b/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordCommand.cs new file mode 100644 index 00000000..9bcf0f23 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordCommand.cs @@ -0,0 +1,12 @@ +using Features.Auth.Dtos; +using MediatR; + +namespace Features.Auth.Commands.UpdatePassword; + +/// Changes the password of the given user account, verifying the current password first. +/// +/// The authenticated caller's own ID, extracted from the access token -- never bind this from +/// client-supplied input. +/// +public record UpdatePasswordCommand(Guid UserAccountId, string CurrentPassword, string NewPassword) + : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordHandler.cs b/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordHandler.cs new file mode 100644 index 00000000..03bff2b2 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordHandler.cs @@ -0,0 +1,42 @@ +using Domain.Exceptions; +using Features.Auth.Dtos; +using Features.Auth.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Features.Auth.Commands.UpdatePassword; + +/// +/// Handles via , +/// which verifies the current password before hashing and persisting the new one. +/// +public class UpdatePasswordHandler(UserManager userManager) + : IRequestHandler +{ + /// Thrown when the account does not exist. + /// Thrown when the current password does not match. + /// Thrown when the update otherwise fails. + public async Task Handle( + UpdatePasswordCommand request, + CancellationToken cancellationToken + ) + { + ApplicationUser user = + await userManager.FindByIdAsync(request.UserAccountId.ToString()) + ?? throw new NotFoundException("User account not found"); + + IdentityResult result = await userManager.ChangePasswordAsync( + user, + request.CurrentPassword, + request.NewPassword + ); + if (!result.Succeeded) + { + if (result.Errors.Any(e => e.Code == "PasswordMismatch")) + throw new UnauthorizedException("Current password is incorrect"); + throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + return new UpdatePasswordPayload(user.Id, DateTime.UtcNow); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordValidator.cs b/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordValidator.cs new file mode 100644 index 00000000..14b210b6 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdatePassword/UpdatePasswordValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; + +namespace Features.Auth.Commands.UpdatePassword; + +/// Validates , mirroring the password rules in RegisterUserValidator. +public class UpdatePasswordValidator : AbstractValidator +{ + public UpdatePasswordValidator() + { + RuleFor(x => x.CurrentPassword).NotEmpty().WithMessage("Current password is required"); + + RuleFor(x => x.NewPassword) + .NotEmpty() + .WithMessage("New password is required") + .MinimumLength(8) + .WithMessage("Password must be at least 8 characters") + .Matches("[A-Z]") + .WithMessage("Password must contain at least one uppercase letter") + .Matches("[a-z]") + .WithMessage("Password must contain at least one lowercase letter") + .Matches("[0-9]") + .WithMessage("Password must contain at least one number") + .Matches("[^a-zA-Z0-9]") + .WithMessage("Password must contain at least one special character") + .NotEqual(x => x.CurrentPassword) + .WithMessage("New password must be different from the current password"); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileCommand.cs b/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileCommand.cs new file mode 100644 index 00000000..9ce21423 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileCommand.cs @@ -0,0 +1,19 @@ +using Features.Auth.Dtos; +using MediatR; + +namespace Features.Auth.Commands.UpdateProfile; + +/// +/// Updates the non-credential profile fields (first name, last name, date of birth) of the given +/// user account. +/// +/// +/// The authenticated caller's own ID, extracted from the access token -- never bind this from +/// client-supplied input. +/// +public record UpdateProfileCommand( + Guid UserAccountId, + string FirstName, + string LastName, + DateTime DateOfBirth +) : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileHandler.cs b/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileHandler.cs new file mode 100644 index 00000000..eb47adec --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileHandler.cs @@ -0,0 +1,38 @@ +using Domain.Exceptions; +using Features.Auth.Dtos; +using Features.Auth.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Features.Auth.Commands.UpdateProfile; + +/// +/// Handles via . First/last name +/// and date of birth aren't tracked by any Identity-specific store interface, so the mutated +/// is saved directly through . +/// +public class UpdateProfileHandler(UserManager userManager) + : IRequestHandler +{ + /// Thrown when the account does not exist. + /// Thrown when the update otherwise fails. + public async Task Handle( + UpdateProfileCommand request, + CancellationToken cancellationToken + ) + { + ApplicationUser user = + await userManager.FindByIdAsync(request.UserAccountId.ToString()) + ?? throw new NotFoundException("User account not found"); + + user.FirstName = request.FirstName; + user.LastName = request.LastName; + user.DateOfBirth = request.DateOfBirth; + + IdentityResult result = await userManager.UpdateAsync(user); + if (!result.Succeeded) + throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description))); + + return new UpdateProfilePayload(user.Id, user.FirstName, user.LastName, user.DateOfBirth); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileValidator.cs b/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileValidator.cs new file mode 100644 index 00000000..6b35ef5b --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdateProfile/UpdateProfileValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; + +namespace Features.Auth.Commands.UpdateProfile; + +/// Validates , mirroring the profile rules in RegisterUserValidator. +public class UpdateProfileValidator : AbstractValidator +{ + public UpdateProfileValidator() + { + RuleFor(x => x.FirstName) + .NotEmpty() + .WithMessage("First name is required") + .MaximumLength(128) + .WithMessage("First name cannot exceed 128 characters"); + + RuleFor(x => x.LastName) + .NotEmpty() + .WithMessage("Last name is required") + .MaximumLength(128) + .WithMessage("Last name cannot exceed 128 characters"); + + RuleFor(x => x.DateOfBirth) + .NotEmpty() + .WithMessage("Date of birth is required") + .LessThan(DateTime.Today.AddYears(-19)) + .WithMessage("You must be at least 19 years old"); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameCommand.cs b/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameCommand.cs new file mode 100644 index 00000000..57cb68db --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameCommand.cs @@ -0,0 +1,12 @@ +using Features.Auth.Dtos; +using MediatR; + +namespace Features.Auth.Commands.UpdateUsername; + +/// Changes the username of the given user account. +/// +/// The authenticated caller's own ID, extracted from the access token -- never bind this from +/// client-supplied input. +/// +public record UpdateUsernameCommand(Guid UserAccountId, string NewUsername) + : IRequest; diff --git a/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameHandler.cs b/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameHandler.cs new file mode 100644 index 00000000..b19828ba --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameHandler.cs @@ -0,0 +1,34 @@ +using Domain.Exceptions; +using Features.Auth.Dtos; +using Features.Auth.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Features.Auth.Commands.UpdateUsername; + +/// Handles via . +public class UpdateUsernameHandler(UserManager userManager) + : IRequestHandler +{ + /// Thrown when the account does not exist. + /// Thrown when the requested username is already taken. + public async Task Handle( + UpdateUsernameCommand request, + CancellationToken cancellationToken + ) + { + ApplicationUser user = + await userManager.FindByIdAsync(request.UserAccountId.ToString()) + ?? throw new NotFoundException("User account not found"); + + IdentityResult result = await userManager.SetUserNameAsync(user, request.NewUsername); + if (!result.Succeeded) + { + if (result.Errors.Any(e => e.Code == "DuplicateUserName")) + throw new ConflictException("Username already exists"); + throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + return new UpdateUsernamePayload(user.Id, user.UserName); + } +} diff --git a/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameValidator.cs b/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameValidator.cs new file mode 100644 index 00000000..175ad4f5 --- /dev/null +++ b/web/backend/Features/Features.Auth/Commands/UpdateUsername/UpdateUsernameValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace Features.Auth.Commands.UpdateUsername; + +/// Validates , mirroring the username rules in RegisterUserValidator. +public class UpdateUsernameValidator : AbstractValidator +{ + public UpdateUsernameValidator() + { + RuleFor(x => x.NewUsername) + .NotEmpty() + .WithMessage("Username is required") + .Length(3, 64) + .WithMessage("Username must be between 3 and 64 characters") + .Matches("^[a-zA-Z0-9._-]+$") + .WithMessage( + "Username can only contain letters, numbers, dots, underscores, and hyphens" + ); + } +} diff --git a/web/backend/Features/Features.Auth/Controllers/AuthController.cs b/web/backend/Features/Features.Auth/Controllers/AuthController.cs index 92de4b50..51d7751d 100644 --- a/web/backend/Features/Features.Auth/Controllers/AuthController.cs +++ b/web/backend/Features/Features.Auth/Controllers/AuthController.cs @@ -1,8 +1,15 @@ +using System.IdentityModel.Tokens.Jwt; +using Domain.Exceptions; using Features.Auth.Commands.ConfirmUser; +using Features.Auth.Commands.DeleteAccount; using Features.Auth.Commands.Login; using Features.Auth.Commands.RefreshToken; using Features.Auth.Commands.RegisterUser; using Features.Auth.Commands.ResendConfirmationEmail; +using Features.Auth.Commands.UpdateEmail; +using Features.Auth.Commands.UpdatePassword; +using Features.Auth.Commands.UpdateProfile; +using Features.Auth.Commands.UpdateUsername; using Features.Auth.Dtos; using MediatR; using Microsoft.AspNetCore.Authorization; @@ -12,7 +19,8 @@ using Shared.Contracts; namespace Features.Auth.Controllers; /// -/// Handles user authentication concerns: registration, login, email confirmation, and token refresh. +/// Handles user authentication concerns: registration, login, email confirmation, token refresh, and +/// self-service account management. /// /// /// The controller is decorated with [Authorize(AuthenticationSchemes = "JWT")] by default, but most @@ -23,6 +31,18 @@ namespace Features.Auth.Controllers; [Authorize(AuthenticationSchemes = "JWT")] public class AuthController(IMediator mediator) : ControllerBase { + /// + /// Extracts the authenticated caller's user ID from the validated access token's sub claim. + /// + /// Thrown when the claim is missing or malformed. + private Guid GetAuthenticatedUserId() + { + string? userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; + if (!Guid.TryParse(userIdClaim, out Guid userId)) + throw new UnauthorizedException("Access token is missing a valid user ID claim"); + return userId; + } + /// Registers a new user account. /// /// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens. @@ -98,16 +118,19 @@ public class AuthController(IMediator mediator) : ControllerBase /// Exchanges a valid refresh token for a new access/refresh token pair. /// - /// Anonymous access. Returns 200 OK with the new tokens, or 401 Unauthorized if the refresh token is - /// invalid or expired. + /// Anonymous access. The refresh token is supplied via the X-Refresh-Token header, not the + /// request body. Returns 200 OK with the new tokens, 400 Bad Request if the header is missing, or + /// 401 Unauthorized if the refresh token is invalid or expired. /// [AllowAnonymous] [HttpPost("refresh")] public async Task>> Refresh( - [FromBody] RefreshTokenCommand command + [FromHeader(Name = "X-Refresh-Token")] string? refreshToken ) { - LoginPayload payload = await mediator.Send(command); + LoginPayload payload = await mediator.Send( + new RefreshTokenCommand(refreshToken ?? string.Empty) + ); return Ok( new ResponseBody { @@ -116,4 +139,107 @@ public class AuthController(IMediator mediator) : ControllerBase } ); } + + /// Changes the authenticated user's username. + /// Returns 200 OK with the updated username, or 409 Conflict if it is already taken. + [HttpPatch("username")] + public async Task>> UpdateUsername( + [FromBody] UpdateUsernameRequest request + ) + { + UpdateUsernamePayload payload = await mediator.Send( + new UpdateUsernameCommand(GetAuthenticatedUserId(), request.NewUsername) + ); + return Ok( + new ResponseBody + { + Message = "Username updated successfully.", + Payload = payload, + } + ); + } + + /// Changes the authenticated user's email address. + /// + /// Returns 200 OK with the updated (now unconfirmed) email address, or 409 Conflict if it is + /// already in use. The caller should use POST /api/auth/confirm/resend to re-confirm. + /// + [HttpPatch("email")] + public async Task>> UpdateEmail( + [FromBody] UpdateEmailRequest request + ) + { + UpdateEmailPayload payload = await mediator.Send( + new UpdateEmailCommand(GetAuthenticatedUserId(), request.NewEmail) + ); + return Ok( + new ResponseBody + { + Message = "Email updated successfully.", + Payload = payload, + } + ); + } + + /// Changes the authenticated user's password. + /// + /// Returns 200 OK on success, or 401 Unauthorized if CurrentPassword does not match the + /// account's current password. + /// + [HttpPatch("password")] + public async Task>> UpdatePassword( + [FromBody] UpdatePasswordRequest request + ) + { + UpdatePasswordPayload payload = await mediator.Send( + new UpdatePasswordCommand( + GetAuthenticatedUserId(), + request.CurrentPassword, + request.NewPassword + ) + ); + return Ok( + new ResponseBody + { + Message = "Password updated successfully.", + Payload = payload, + } + ); + } + + /// Updates the authenticated user's profile fields (first name, last name, date of birth). + /// Returns 200 OK with the updated profile. + [HttpPatch("profile")] + public async Task>> UpdateProfile( + [FromBody] UpdateProfileRequest request + ) + { + UpdateProfilePayload payload = await mediator.Send( + new UpdateProfileCommand( + GetAuthenticatedUserId(), + request.FirstName, + request.LastName, + request.DateOfBirth + ) + ); + return Ok( + new ResponseBody + { + Message = "Profile updated successfully.", + Payload = payload, + } + ); + } + + /// Permanently deletes the authenticated user's account. + /// + /// Returns 200 OK on success, or 409 Conflict if the account still has associated posts, comments, + /// photos, or follows. + /// + [HttpDelete("account")] + public async Task> DeleteAccount() + { + await mediator.Send(new DeleteAccountCommand(GetAuthenticatedUserId())); + return Ok(new ResponseBody { Message = "Account deleted successfully." }); + } } diff --git a/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs b/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs index 43070541..f6c9e9ba 100644 --- a/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs +++ b/web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs @@ -19,3 +19,48 @@ public record RegistrationPayload( /// Payload returned to the client after a user account's email has been confirmed. public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate); + +/// Request body for changing the authenticated user's username. +/// +/// The desired username; must be 3-64 characters and contain only letters, numbers, dots, +/// underscores, and hyphens. +/// +public record UpdateUsernameRequest(string NewUsername); + +/// Payload returned to the client after a username change. +public record UpdateUsernamePayload(Guid UserAccountId, string Username); + +/// Request body for changing the authenticated user's email address. +/// The new email address; up to 128 characters and must be a valid email format. +public record UpdateEmailRequest(string NewEmail); + +/// +/// Payload returned to the client after an email change. The new address is unconfirmed until the +/// user completes the confirmation flow again. +/// +public record UpdateEmailPayload(Guid UserAccountId, string Email, bool EmailConfirmed); + +/// Request body for updating the authenticated user's profile fields. +/// The user's first name; up to 128 characters. +/// The user's last name; up to 128 characters. +/// The user's date of birth; the user must be at least 19 years old. +public record UpdateProfileRequest(string FirstName, string LastName, DateTime DateOfBirth); + +/// Payload returned to the client after a profile update. +public record UpdateProfilePayload( + Guid UserAccountId, + string FirstName, + string LastName, + DateTime DateOfBirth +); + +/// Request body for changing the authenticated user's password. +/// The user's current plaintext password, verified before the change is applied. +/// +/// The desired new plaintext password; must be at least 8 characters and contain an uppercase letter, a +/// lowercase letter, a number, and a special character. +/// +public record UpdatePasswordRequest(string CurrentPassword, string NewPassword); + +/// Payload returned to the client after a password change. +public record UpdatePasswordPayload(Guid UserAccountId, DateTime ChangedAt); diff --git a/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs b/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs index a901bd58..e0dd41d5 100644 --- a/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs +++ b/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs @@ -13,6 +13,8 @@ public sealed class ApplicationUser public string Email { get; set; } = string.Empty; public string? NormalizedEmail { get; set; } public bool EmailConfirmed { get; set; } + + /// The current Argon2 hash, or if the account has no password set. public string? PasswordHash { get; set; } public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; diff --git a/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs b/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs index a8bcd37f..76355634 100644 --- a/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs +++ b/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs @@ -3,12 +3,20 @@ using Microsoft.AspNetCore.Identity; namespace Features.Auth.Identity; +/// Adapts 's Argon2 hashing to . public sealed class Argon2PasswordHasher(IPasswordInfrastructure passwordInfrastructure) : IPasswordHasher { + /// public string HashPassword(ApplicationUser user, string password) => passwordInfrastructure.Hash(password); + /// + /// + /// Never returns : Argon2 parameters are + /// fixed by , so 's automatic + /// rehash-on-verify path never triggers. + /// public PasswordVerificationResult VerifyHashedPassword( ApplicationUser user, string hashedPassword, diff --git a/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs b/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs index f555f3fc..24b4433e 100644 --- a/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs +++ b/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs @@ -25,14 +25,18 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) FROM dbo.UserAccount """; + /// No unmanaged resources to release; each method opens and disposes its own connection. public void Dispose() { } + /// public Task GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) => Task.FromResult(user.Id.ToString()); + /// public Task GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken) => Task.FromResult(user.UserName); + /// public Task SetUserNameAsync( ApplicationUser user, string? userName, @@ -43,11 +47,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) return Task.CompletedTask; } + /// public Task GetNormalizedUserNameAsync( ApplicationUser user, CancellationToken cancellationToken ) => Task.FromResult(user.NormalizedUserName); + /// public Task SetNormalizedUserNameAsync( ApplicationUser user, string? normalizedName, @@ -58,6 +64,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) return Task.CompletedTask; } + /// /// /// Inserts the account and its initial credential within a single database transaction, mirroring the /// previous AuthRepository.RegisterUserAsync behavior. already @@ -131,10 +138,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) return IdentityResult.Success; } + /// /// - /// Persists profile field changes and, when is - /// , ensures a corresponding dbo.UserVerification row exists (idempotently, - /// matching the previous AuthRepository.ConfirmUserAccountAsync behavior). + /// Persists profile field changes and keeps dbo.UserVerification in sync with + /// : a row is inserted (idempotently, matching the + /// previous AuthRepository.ConfirmUserAccountAsync behavior) when + /// , and removed when -- notably after + /// resets confirmation on an email change. /// public async Task UpdateAsync( ApplicationUser user, @@ -166,15 +176,54 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) if (user.EmailConfirmed) await EnsureVerificationRowAsync(connection, user.Id, cancellationToken); + else + await RemoveVerificationRowAsync(connection, user.Id, cancellationToken); return IdentityResult.Success; } - public Task DeleteAsync( + /// + /// + /// A plain DELETE against dbo.UserAccount: its foreign keys from + /// dbo.UserCredential, dbo.UserVerification, and dbo.UserAvatar cascade, so no + /// manual child cleanup is needed. Other tables (posts, comments, photos, follows) reference the + /// account with ON DELETE NO ACTION by design; deleting an account that still has any of + /// those fails with a foreign key violation, which is surfaced as a failed + /// rather than an unhandled . + /// + public async Task DeleteAsync( ApplicationUser user, CancellationToken cancellationToken - ) => throw new NotSupportedException("User deletion is not supported."); + ) + { + await using DbConnection connection = await CreateConnection(); + try + { + await connection.ExecuteAsync( + new CommandDefinition( + "DELETE FROM dbo.UserAccount WHERE UserAccountID = @Id", + new { user.Id }, + cancellationToken: cancellationToken + ) + ); + } + catch (SqlException ex) when (IsForeignKeyViolation(ex)) + { + return IdentityResult.Failed( + new IdentityError + { + Code = "AccountHasDependentRecords", + Description = + "Account cannot be deleted because other records still reference it.", + } + ); + } + + return IdentityResult.Success; + } + + /// public async Task FindByIdAsync( string userId, CancellationToken cancellationToken @@ -198,6 +247,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) return user; } + /// public async Task FindByNameAsync( string normalizedUserName, CancellationToken cancellationToken @@ -218,24 +268,79 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) return user; } - public Task SetPasswordHashAsync( + /// + /// + /// For an existing account ( already assigned), rotates the + /// credential transactionally -- revoking the previous dbo.UserCredential row(s) and inserting + /// the new hash -- mirroring the previous AuthRepository.RotateCredentialAsync behavior. During + /// registration calls this before the account has an ID (i.e. before + /// runs); in that case only the in-memory hash is set, and + /// writes the initial credential itself as part of its own transaction. + /// + public async Task SetPasswordHashAsync( ApplicationUser user, string? passwordHash, CancellationToken cancellationToken ) { + string? previousHash = user.PasswordHash; user.PasswordHash = passwordHash; - return Task.CompletedTask; + + if (user.Id == Guid.Empty || passwordHash == null || passwordHash == previousHash) + return; + + await using DbConnection connection = await CreateConnection(); + await using DbTransaction transaction = await connection.BeginTransactionAsync( + cancellationToken + ); + + try + { + await connection.ExecuteAsync( + new CommandDefinition( + """ + UPDATE dbo.UserCredential + SET IsRevoked = 1, RevokedAt = GETDATE() + WHERE UserAccountId = @UserAccountId AND IsRevoked = 0 + """, + new { UserAccountId = user.Id }, + transaction, + cancellationToken: cancellationToken + ) + ); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserCredential (UserAccountId, Hash) + VALUES (@UserAccountId, @Hash); + """, + new { UserAccountId = user.Id, Hash = passwordHash }, + transaction, + cancellationToken: cancellationToken + ) + ); + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } } + /// public Task GetPasswordHashAsync( ApplicationUser user, CancellationToken cancellationToken ) => Task.FromResult(user.PasswordHash); + /// public Task HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) => Task.FromResult(user.PasswordHash != null); + /// public Task SetEmailAsync( ApplicationUser user, string? email, @@ -246,14 +351,17 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) return Task.CompletedTask; } + /// public Task GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) => Task.FromResult(user.Email); + /// public Task GetEmailConfirmedAsync( ApplicationUser user, CancellationToken cancellationToken ) => Task.FromResult(user.EmailConfirmed); + /// public Task SetEmailConfirmedAsync( ApplicationUser user, bool confirmed, @@ -264,6 +372,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) return Task.CompletedTask; } + /// public async Task FindByEmailAsync( string normalizedEmail, CancellationToken cancellationToken @@ -284,11 +393,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) return user; } + /// public Task GetNormalizedEmailAsync( ApplicationUser user, CancellationToken cancellationToken ) => Task.FromResult(user.NormalizedEmail); + /// public Task SetNormalizedEmailAsync( ApplicationUser user, string? normalizedEmail, @@ -376,6 +487,23 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory) } } + /// Removes the dbo.UserVerification row for a user, if one exists. + private static Task RemoveVerificationRowAsync( + DbConnection connection, + Guid userAccountId, + CancellationToken cancellationToken + ) => + connection.ExecuteAsync( + new CommandDefinition( + "DELETE FROM dbo.UserVerification WHERE UserAccountID = @UserAccountId", + new { UserAccountId = userAccountId }, + cancellationToken: cancellationToken + ) + ); + /// Determines whether a is a duplicate key violation (SQL Server error 2601 or 2627). private static bool IsDuplicateKeyViolation(SqlException ex) => ex.Number is 2601 or 2627; + + /// Determines whether a is a foreign key constraint violation (SQL Server error 547). + private static bool IsForeignKeyViolation(SqlException ex) => ex.Number == 547; } diff --git a/web/backend/Features/Features.Auth/Identity/UserAccountExtensions.cs b/web/backend/Features/Features.Auth/Identity/UserAccountExtensions.cs new file mode 100644 index 00000000..67c270bb --- /dev/null +++ b/web/backend/Features/Features.Auth/Identity/UserAccountExtensions.cs @@ -0,0 +1,23 @@ +using Domain.Entities; + +namespace Features.Auth.Identity; + +/// +/// Converts the persistence-agnostic domain entity into the +/// shape required by . +/// Kept here, in Features.Auth, rather than on itself so the domain layer has +/// no dependency on ASP.NET Core Identity. +/// +public static class UserAccountExtensions +{ + public static ApplicationUser ToApplicationUser(this UserAccount userAccount) => + new() + { + Id = userAccount.UserAccountId, + UserName = userAccount.Username, + Email = userAccount.Email, + FirstName = userAccount.FirstName, + LastName = userAccount.LastName, + DateOfBirth = userAccount.DateOfBirth, + }; +}