Add self-service account management commands (delete, update username/email/password/profile)

This commit is contained in:
Aaron Po
2026-08-22 17:24:53 -04:00
parent 93e0d089f9
commit c380bebdd6
30 changed files with 1143 additions and 42 deletions

View File

@@ -1,7 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shared.Contracts; using Shared.Contracts;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
namespace API.Core.Controllers; namespace API.Core.Controllers;
@@ -20,8 +20,8 @@ public class ProtectedController : ControllerBase
[HttpGet] [HttpGet]
public ActionResult<ResponseBody<object>> Get() public ActionResult<ResponseBody<object>> Get()
{ {
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; string? userId = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
string? username = User.FindFirst(ClaimTypes.Name)?.Value; string? username = User.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
return Ok( return Ok(
new ResponseBody<object> new ResponseBody<object>

View File

@@ -12,6 +12,7 @@ using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Infrastructure.Sql.DependencyInjection; using Infrastructure.Sql.DependencyInjection;
using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors; using Shared.Application.Behaviors;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -27,7 +28,37 @@ builder
.AddApplicationPart(typeof(AuthController).Assembly); .AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddEndpointsApiExplorer(); 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(); builder.Services.AddOpenApi();
// Add FluentValidation // Add FluentValidation

View File

@@ -602,12 +602,9 @@ public class AuthSteps(ScenarioContext scenario)
string? token = scenario.TryGetValue<string>("refreshToken", out string? t) string? token = scenario.TryGetValue<string>("refreshToken", out string? t)
? t ? t
: "valid-refresh-token"; : "valid-refresh-token";
string body = JsonSerializer.Serialize(new { refreshToken = token });
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh") HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
{ requestMessage.Headers.Add("X-Refresh-Token", token);
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
@@ -619,12 +616,9 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken() public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string body = JsonSerializer.Serialize(new { refreshToken = "invalid-refresh-token" });
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh") HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
{ requestMessage.Headers.Add("X-Refresh-Token", "invalid-refresh-token");
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
@@ -636,13 +630,9 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken() public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
// Use an expired token
string body = JsonSerializer.Serialize(new { refreshToken = "expired-refresh-token" });
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh") HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
{ requestMessage.Headers.Add("X-Refresh-Token", "expired-refresh-token");
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
@@ -654,12 +644,8 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken() public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string body = JsonSerializer.Serialize(new { });
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh") HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();

View File

@@ -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<UserManager<ApplicationUser>> _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<Task> act = async () =>
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
[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<Task> act = async () =>
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>();
}
}

View File

@@ -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<UserManager<ApplicationUser>> _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<ApplicationUser, string>(
(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<Task<UpdateEmailPayload>> act = async () =>
await _handler.Handle(
new UpdateEmailCommand(userId, "new@example.com"),
CancellationToken.None
);
await act.Should().ThrowAsync<NotFoundException>();
}
[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<Task<UpdateEmailPayload>> act = async () =>
await _handler.Handle(
new UpdateEmailCommand(userId, "taken@example.com"),
CancellationToken.None
);
await act.Should().ThrowAsync<ConflictException>();
}
}

View File

@@ -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<UserManager<ApplicationUser>> _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<Task<UpdatePasswordPayload>> act = async () =>
await _handler.Handle(
new UpdatePasswordCommand(userId, "OldPassword1!", "NewPassword1!"),
CancellationToken.None
);
await act.Should().ThrowAsync<NotFoundException>();
}
[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<Task<UpdatePasswordPayload>> act = async () =>
await _handler.Handle(
new UpdatePasswordCommand(userId, "WrongPassword1!", "NewPassword1!"),
CancellationToken.None
);
await act.Should().ThrowAsync<UnauthorizedException>();
}
}

View File

@@ -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<UserManager<ApplicationUser>> _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<Task<UpdateProfilePayload>> act = async () =>
await _handler.Handle(
new UpdateProfileCommand(userId, "New", "Person", DateTime.Today),
CancellationToken.None
);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -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<UserManager<ApplicationUser>> _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<ApplicationUser, string>((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<Task<UpdateUsernamePayload>> act = async () =>
await _handler.Handle(
new UpdateUsernameCommand(userId, "new-name"),
CancellationToken.None
);
await act.Should().ThrowAsync<NotFoundException>();
}
[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<Task<UpdateUsernamePayload>> act = async () =>
await _handler.Handle(
new UpdateUsernameCommand(userId, "taken-name"),
CancellationToken.None
);
await act.Should().ThrowAsync<ConflictException>();
}
}

View File

@@ -0,0 +1,10 @@
using MediatR;
namespace Features.Auth.Commands.DeleteAccount;
/// <summary>Permanently deletes the given user account.</summary>
/// <param name="UserAccountId">
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
/// client-supplied input.
/// </param>
public record DeleteAccountCommand(Guid UserAccountId) : IRequest;

View File

@@ -0,0 +1,33 @@
using Domain.Exceptions;
using Features.Auth.Identity;
using MediatR;
using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.DeleteAccount;
/// <summary>Handles <see cref="DeleteAccountCommand" /> by deleting the user via <see cref="UserManager{TUser}" />.</summary>
public class DeleteAccountHandler(UserManager<ApplicationUser> userManager)
: IRequestHandler<DeleteAccountCommand>
{
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
/// <exception cref="ConflictException">
/// Thrown when the account cannot be deleted because other records (posts, comments, photos,
/// follows) still reference it.
/// </exception>
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)));
}
}
}

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.RefreshToken;
/// <summary> /// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the /// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the
/// request body of <c>POST /api/auth/refresh</c>. /// <c>X-Refresh-Token</c> header of <c>POST /api/auth/refresh</c>.
/// </summary> /// </summary>
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>; public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
@@ -28,14 +29,14 @@ public class RegisterUserHandler(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
ApplicationUser user = new() ApplicationUser user = new UserAccount
{ {
UserName = request.Username, Username = request.Username,
Email = request.Email, Email = request.Email,
FirstName = request.FirstName, FirstName = request.FirstName,
LastName = request.LastName, LastName = request.LastName,
DateOfBirth = request.DateOfBirth, DateOfBirth = request.DateOfBirth,
}; }.ToApplicationUser();
IdentityResult result = await userManager.CreateAsync(user, request.Password); IdentityResult result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded) if (!result.Succeeded)

View File

@@ -0,0 +1,14 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.UpdateEmail;
/// <summary>
/// Changes the email address of the given user account. The account's <c>EmailConfirmed</c> status
/// is reset to unconfirmed as a result.
/// </summary>
/// <param name="UserAccountId">
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
/// client-supplied input.
/// </param>
public record UpdateEmailCommand(Guid UserAccountId, string NewEmail) : IRequest<UpdateEmailPayload>;

View File

@@ -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;
/// <summary>
/// Handles <see cref="UpdateEmailCommand" /> via <see cref="UserManager{TUser}" />.
/// <see cref="UserManager{TUser}.SetEmailAsync" /> also resets <c>EmailConfirmed</c> to
/// <see langword="false" />, so the user must re-confirm the new address (see
/// <c>Features.Auth.Commands.ResendConfirmationEmail</c>).
/// </summary>
public class UpdateEmailHandler(UserManager<ApplicationUser> userManager)
: IRequestHandler<UpdateEmailCommand, UpdateEmailPayload>
{
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
/// <exception cref="ConflictException">Thrown when the requested email address is already in use.</exception>
public async Task<UpdateEmailPayload> 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);
}
}

View File

@@ -0,0 +1,18 @@
using FluentValidation;
namespace Features.Auth.Commands.UpdateEmail;
/// <summary>Validates <see cref="UpdateEmailCommand" />, mirroring the email rules in <c>RegisterUserValidator</c>.</summary>
public class UpdateEmailValidator : AbstractValidator<UpdateEmailCommand>
{
public UpdateEmailValidator()
{
RuleFor(x => x.NewEmail)
.NotEmpty()
.WithMessage("Email is required")
.EmailAddress()
.WithMessage("Invalid email format")
.MaximumLength(128)
.WithMessage("Email cannot exceed 128 characters");
}
}

View File

@@ -0,0 +1,12 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.UpdatePassword;
/// <summary>Changes the password of the given user account, verifying the current password first.</summary>
/// <param name="UserAccountId">
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
/// client-supplied input.
/// </param>
public record UpdatePasswordCommand(Guid UserAccountId, string CurrentPassword, string NewPassword)
: IRequest<UpdatePasswordPayload>;

View File

@@ -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;
/// <summary>
/// Handles <see cref="UpdatePasswordCommand" /> via <see cref="UserManager{TUser}.ChangePasswordAsync" />,
/// which verifies the current password before hashing and persisting the new one.
/// </summary>
public class UpdatePasswordHandler(UserManager<ApplicationUser> userManager)
: IRequestHandler<UpdatePasswordCommand, UpdatePasswordPayload>
{
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
/// <exception cref="UnauthorizedException">Thrown when the current password does not match.</exception>
/// <exception cref="ConflictException">Thrown when the update otherwise fails.</exception>
public async Task<UpdatePasswordPayload> 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);
}
}

View File

@@ -0,0 +1,28 @@
using FluentValidation;
namespace Features.Auth.Commands.UpdatePassword;
/// <summary>Validates <see cref="UpdatePasswordCommand" />, mirroring the password rules in <c>RegisterUserValidator</c>.</summary>
public class UpdatePasswordValidator : AbstractValidator<UpdatePasswordCommand>
{
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");
}
}

View File

@@ -0,0 +1,19 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.UpdateProfile;
/// <summary>
/// Updates the non-credential profile fields (first name, last name, date of birth) of the given
/// user account.
/// </summary>
/// <param name="UserAccountId">
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
/// client-supplied input.
/// </param>
public record UpdateProfileCommand(
Guid UserAccountId,
string FirstName,
string LastName,
DateTime DateOfBirth
) : IRequest<UpdateProfilePayload>;

View File

@@ -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;
/// <summary>
/// Handles <see cref="UpdateProfileCommand" /> via <see cref="UserManager{TUser}" />. First/last name
/// and date of birth aren't tracked by any Identity-specific store interface, so the mutated
/// <see cref="ApplicationUser" /> is saved directly through <see cref="UserManager{TUser}.UpdateAsync" />.
/// </summary>
public class UpdateProfileHandler(UserManager<ApplicationUser> userManager)
: IRequestHandler<UpdateProfileCommand, UpdateProfilePayload>
{
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
/// <exception cref="ConflictException">Thrown when the update otherwise fails.</exception>
public async Task<UpdateProfilePayload> 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);
}
}

View File

@@ -0,0 +1,28 @@
using FluentValidation;
namespace Features.Auth.Commands.UpdateProfile;
/// <summary>Validates <see cref="UpdateProfileCommand" />, mirroring the profile rules in <c>RegisterUserValidator</c>.</summary>
public class UpdateProfileValidator : AbstractValidator<UpdateProfileCommand>
{
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");
}
}

View File

@@ -0,0 +1,12 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.UpdateUsername;
/// <summary>Changes the username of the given user account.</summary>
/// <param name="UserAccountId">
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
/// client-supplied input.
/// </param>
public record UpdateUsernameCommand(Guid UserAccountId, string NewUsername)
: IRequest<UpdateUsernamePayload>;

View File

@@ -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;
/// <summary>Handles <see cref="UpdateUsernameCommand" /> via <see cref="UserManager{TUser}" />.</summary>
public class UpdateUsernameHandler(UserManager<ApplicationUser> userManager)
: IRequestHandler<UpdateUsernameCommand, UpdateUsernamePayload>
{
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
/// <exception cref="ConflictException">Thrown when the requested username is already taken.</exception>
public async Task<UpdateUsernamePayload> 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);
}
}

View File

@@ -0,0 +1,20 @@
using FluentValidation;
namespace Features.Auth.Commands.UpdateUsername;
/// <summary>Validates <see cref="UpdateUsernameCommand" />, mirroring the username rules in <c>RegisterUserValidator</c>.</summary>
public class UpdateUsernameValidator : AbstractValidator<UpdateUsernameCommand>
{
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"
);
}
}

View File

@@ -1,8 +1,15 @@
using System.IdentityModel.Tokens.Jwt;
using Domain.Exceptions;
using Features.Auth.Commands.ConfirmUser; using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Commands.DeleteAccount;
using Features.Auth.Commands.Login; using Features.Auth.Commands.Login;
using Features.Auth.Commands.RefreshToken; using Features.Auth.Commands.RefreshToken;
using Features.Auth.Commands.RegisterUser; using Features.Auth.Commands.RegisterUser;
using Features.Auth.Commands.ResendConfirmationEmail; 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 Features.Auth.Dtos;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -12,7 +19,8 @@ using Shared.Contracts;
namespace Features.Auth.Controllers; namespace Features.Auth.Controllers;
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most /// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
@@ -23,6 +31,18 @@ namespace Features.Auth.Controllers;
[Authorize(AuthenticationSchemes = "JWT")] [Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase public class AuthController(IMediator mediator) : ControllerBase
{ {
/// <summary>
/// Extracts the authenticated caller's user ID from the validated access token's <c>sub</c> claim.
/// </summary>
/// <exception cref="UnauthorizedException">Thrown when the claim is missing or malformed.</exception>
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;
}
/// <summary>Registers a new user account.</summary> /// <summary>Registers a new user account.</summary>
/// <remarks> /// <remarks>
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens. /// 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
/// <summary>Exchanges a valid refresh token for a new access/refresh token pair.</summary> /// <summary>Exchanges a valid refresh token for a new access/refresh token pair.</summary>
/// <remarks> /// <remarks>
/// Anonymous access. Returns 200 OK with the new tokens, or 401 Unauthorized if the refresh token is /// Anonymous access. The refresh token is supplied via the <c>X-Refresh-Token</c> header, not the
/// invalid or expired. /// 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.
/// </remarks> /// </remarks>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh( public async Task<ActionResult<ResponseBody<LoginPayload>>> 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( return Ok(
new ResponseBody<LoginPayload> new ResponseBody<LoginPayload>
{ {
@@ -116,4 +139,107 @@ public class AuthController(IMediator mediator) : ControllerBase
} }
); );
} }
/// <summary>Changes the authenticated user's username.</summary>
/// <remarks>Returns 200 OK with the updated username, or 409 Conflict if it is already taken.</remarks>
[HttpPatch("username")]
public async Task<ActionResult<ResponseBody<UpdateUsernamePayload>>> UpdateUsername(
[FromBody] UpdateUsernameRequest request
)
{
UpdateUsernamePayload payload = await mediator.Send(
new UpdateUsernameCommand(GetAuthenticatedUserId(), request.NewUsername)
);
return Ok(
new ResponseBody<UpdateUsernamePayload>
{
Message = "Username updated successfully.",
Payload = payload,
}
);
}
/// <summary>Changes the authenticated user's email address.</summary>
/// <remarks>
/// Returns 200 OK with the updated (now unconfirmed) email address, or 409 Conflict if it is
/// already in use. The caller should use <c>POST /api/auth/confirm/resend</c> to re-confirm.
/// </remarks>
[HttpPatch("email")]
public async Task<ActionResult<ResponseBody<UpdateEmailPayload>>> UpdateEmail(
[FromBody] UpdateEmailRequest request
)
{
UpdateEmailPayload payload = await mediator.Send(
new UpdateEmailCommand(GetAuthenticatedUserId(), request.NewEmail)
);
return Ok(
new ResponseBody<UpdateEmailPayload>
{
Message = "Email updated successfully.",
Payload = payload,
}
);
}
/// <summary>Changes the authenticated user's password.</summary>
/// <remarks>
/// Returns 200 OK on success, or 401 Unauthorized if <c>CurrentPassword</c> does not match the
/// account's current password.
/// </remarks>
[HttpPatch("password")]
public async Task<ActionResult<ResponseBody<UpdatePasswordPayload>>> UpdatePassword(
[FromBody] UpdatePasswordRequest request
)
{
UpdatePasswordPayload payload = await mediator.Send(
new UpdatePasswordCommand(
GetAuthenticatedUserId(),
request.CurrentPassword,
request.NewPassword
)
);
return Ok(
new ResponseBody<UpdatePasswordPayload>
{
Message = "Password updated successfully.",
Payload = payload,
}
);
}
/// <summary>Updates the authenticated user's profile fields (first name, last name, date of birth).</summary>
/// <remarks>Returns 200 OK with the updated profile.</remarks>
[HttpPatch("profile")]
public async Task<ActionResult<ResponseBody<UpdateProfilePayload>>> UpdateProfile(
[FromBody] UpdateProfileRequest request
)
{
UpdateProfilePayload payload = await mediator.Send(
new UpdateProfileCommand(
GetAuthenticatedUserId(),
request.FirstName,
request.LastName,
request.DateOfBirth
)
);
return Ok(
new ResponseBody<UpdateProfilePayload>
{
Message = "Profile updated successfully.",
Payload = payload,
}
);
}
/// <summary>Permanently deletes the authenticated user's account.</summary>
/// <remarks>
/// Returns 200 OK on success, or 409 Conflict if the account still has associated posts, comments,
/// photos, or follows.
/// </remarks>
[HttpDelete("account")]
public async Task<ActionResult<ResponseBody>> DeleteAccount()
{
await mediator.Send(new DeleteAccountCommand(GetAuthenticatedUserId()));
return Ok(new ResponseBody { Message = "Account deleted successfully." });
}
} }

View File

@@ -19,3 +19,48 @@ public record RegistrationPayload(
/// <summary>Payload returned to the client after a user account's email has been confirmed.</summary> /// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate); public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
/// <summary>Request body for changing the authenticated user's username.</summary>
/// <param name="NewUsername">
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
/// underscores, and hyphens.
/// </param>
public record UpdateUsernameRequest(string NewUsername);
/// <summary>Payload returned to the client after a username change.</summary>
public record UpdateUsernamePayload(Guid UserAccountId, string Username);
/// <summary>Request body for changing the authenticated user's email address.</summary>
/// <param name="NewEmail">The new email address; up to 128 characters and must be a valid email format.</param>
public record UpdateEmailRequest(string NewEmail);
/// <summary>
/// Payload returned to the client after an email change. The new address is unconfirmed until the
/// user completes the confirmation flow again.
/// </summary>
public record UpdateEmailPayload(Guid UserAccountId, string Email, bool EmailConfirmed);
/// <summary>Request body for updating the authenticated user's profile fields.</summary>
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
/// <param name="LastName">The user's last name; up to 128 characters.</param>
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
public record UpdateProfileRequest(string FirstName, string LastName, DateTime DateOfBirth);
/// <summary>Payload returned to the client after a profile update.</summary>
public record UpdateProfilePayload(
Guid UserAccountId,
string FirstName,
string LastName,
DateTime DateOfBirth
);
/// <summary>Request body for changing the authenticated user's password.</summary>
/// <param name="CurrentPassword">The user's current plaintext password, verified before the change is applied.</param>
/// <param name="NewPassword">
/// 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.
/// </param>
public record UpdatePasswordRequest(string CurrentPassword, string NewPassword);
/// <summary>Payload returned to the client after a password change.</summary>
public record UpdatePasswordPayload(Guid UserAccountId, DateTime ChangedAt);

View File

@@ -13,6 +13,8 @@ public sealed class ApplicationUser
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
public string? NormalizedEmail { get; set; } public string? NormalizedEmail { get; set; }
public bool EmailConfirmed { get; set; } public bool EmailConfirmed { get; set; }
/// <summary>The current Argon2 hash, or <see langword="null" /> if the account has no password set.</summary>
public string? PasswordHash { get; set; } public string? PasswordHash { get; set; }
public string FirstName { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty;

View File

@@ -3,12 +3,20 @@ using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Identity; namespace Features.Auth.Identity;
/// <summary>Adapts <see cref="IPasswordInfrastructure" />'s Argon2 hashing to <see cref="IPasswordHasher{TUser}" />.</summary>
public sealed class Argon2PasswordHasher(IPasswordInfrastructure passwordInfrastructure) public sealed class Argon2PasswordHasher(IPasswordInfrastructure passwordInfrastructure)
: IPasswordHasher<ApplicationUser> : IPasswordHasher<ApplicationUser>
{ {
/// <inheritdoc />
public string HashPassword(ApplicationUser user, string password) => public string HashPassword(ApplicationUser user, string password) =>
passwordInfrastructure.Hash(password); passwordInfrastructure.Hash(password);
/// <inheritdoc />
/// <remarks>
/// Never returns <see cref="PasswordVerificationResult.SuccessRehashNeeded" />: Argon2 parameters are
/// fixed by <see cref="IPasswordInfrastructure" />, so <see cref="UserManager{TUser}" />'s automatic
/// rehash-on-verify path never triggers.
/// </remarks>
public PasswordVerificationResult VerifyHashedPassword( public PasswordVerificationResult VerifyHashedPassword(
ApplicationUser user, ApplicationUser user,
string hashedPassword, string hashedPassword,

View File

@@ -25,14 +25,18 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
FROM dbo.UserAccount FROM dbo.UserAccount
"""; """;
/// <remarks>No unmanaged resources to release; each method opens and disposes its own connection.</remarks>
public void Dispose() { } public void Dispose() { }
/// <inheritdoc />
public Task<string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) => public Task<string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) =>
Task.FromResult(user.Id.ToString()); Task.FromResult(user.Id.ToString());
/// <inheritdoc />
public Task<string?> GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken) => public Task<string?> GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken) =>
Task.FromResult<string?>(user.UserName); Task.FromResult<string?>(user.UserName);
/// <inheritdoc />
public Task SetUserNameAsync( public Task SetUserNameAsync(
ApplicationUser user, ApplicationUser user,
string? userName, string? userName,
@@ -43,11 +47,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public Task<string?> GetNormalizedUserNameAsync( public Task<string?> GetNormalizedUserNameAsync(
ApplicationUser user, ApplicationUser user,
CancellationToken cancellationToken CancellationToken cancellationToken
) => Task.FromResult(user.NormalizedUserName); ) => Task.FromResult(user.NormalizedUserName);
/// <inheritdoc />
public Task SetNormalizedUserNameAsync( public Task SetNormalizedUserNameAsync(
ApplicationUser user, ApplicationUser user,
string? normalizedName, string? normalizedName,
@@ -58,6 +64,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
/// <remarks> /// <remarks>
/// Inserts the account and its initial credential within a single database transaction, mirroring the /// Inserts the account and its initial credential within a single database transaction, mirroring the
/// previous <c>AuthRepository.RegisterUserAsync</c> behavior. <see cref="UserManager{TUser}" /> already /// previous <c>AuthRepository.RegisterUserAsync</c> behavior. <see cref="UserManager{TUser}" /> already
@@ -131,10 +138,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
return IdentityResult.Success; return IdentityResult.Success;
} }
/// <inheritdoc />
/// <remarks> /// <remarks>
/// Persists profile field changes and, when <see cref="ApplicationUser.EmailConfirmed" /> is /// Persists profile field changes and keeps <c>dbo.UserVerification</c> in sync with
/// <see langword="true" />, ensures a corresponding <c>dbo.UserVerification</c> row exists (idempotently, /// <see cref="ApplicationUser.EmailConfirmed" />: a row is inserted (idempotently, matching the
/// matching the previous <c>AuthRepository.ConfirmUserAccountAsync</c> behavior). /// previous <c>AuthRepository.ConfirmUserAccountAsync</c> behavior) when
/// <see langword="true" />, and removed when <see langword="false" /> -- notably after
/// <see cref="UserManager{TUser}.SetEmailAsync" /> resets confirmation on an email change.
/// </remarks> /// </remarks>
public async Task<IdentityResult> UpdateAsync( public async Task<IdentityResult> UpdateAsync(
ApplicationUser user, ApplicationUser user,
@@ -166,15 +176,54 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
if (user.EmailConfirmed) if (user.EmailConfirmed)
await EnsureVerificationRowAsync(connection, user.Id, cancellationToken); await EnsureVerificationRowAsync(connection, user.Id, cancellationToken);
else
await RemoveVerificationRowAsync(connection, user.Id, cancellationToken);
return IdentityResult.Success; return IdentityResult.Success;
} }
public Task<IdentityResult> DeleteAsync( /// <inheritdoc />
/// <remarks>
/// A plain <c>DELETE</c> against <c>dbo.UserAccount</c>: its foreign keys from
/// <c>dbo.UserCredential</c>, <c>dbo.UserVerification</c>, and <c>dbo.UserAvatar</c> cascade, so no
/// manual child cleanup is needed. Other tables (posts, comments, photos, follows) reference the
/// account with <c>ON DELETE NO ACTION</c> by design; deleting an account that still has any of
/// those fails with a foreign key violation, which is surfaced as a failed
/// <see cref="IdentityResult" /> rather than an unhandled <see cref="SqlException" />.
/// </remarks>
public async Task<IdentityResult> DeleteAsync(
ApplicationUser user, ApplicationUser user,
CancellationToken cancellationToken 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;
}
/// <inheritdoc />
public async Task<ApplicationUser?> FindByIdAsync( public async Task<ApplicationUser?> FindByIdAsync(
string userId, string userId,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -198,6 +247,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
return user; return user;
} }
/// <inheritdoc />
public async Task<ApplicationUser?> FindByNameAsync( public async Task<ApplicationUser?> FindByNameAsync(
string normalizedUserName, string normalizedUserName,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -218,24 +268,79 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
return user; return user;
} }
public Task SetPasswordHashAsync( /// <inheritdoc />
/// <remarks>
/// For an existing account (<see cref="ApplicationUser.Id" /> already assigned), rotates the
/// credential transactionally -- revoking the previous <c>dbo.UserCredential</c> row(s) and inserting
/// the new hash -- mirroring the previous <c>AuthRepository.RotateCredentialAsync</c> behavior. During
/// registration <see cref="UserManager{TUser}" /> calls this before the account has an ID (i.e. before
/// <see cref="CreateAsync" /> runs); in that case only the in-memory hash is set, and
/// <see cref="CreateAsync" /> writes the initial credential itself as part of its own transaction.
/// </remarks>
public async Task SetPasswordHashAsync(
ApplicationUser user, ApplicationUser user,
string? passwordHash, string? passwordHash,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
string? previousHash = user.PasswordHash;
user.PasswordHash = 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;
}
} }
/// <inheritdoc />
public Task<string?> GetPasswordHashAsync( public Task<string?> GetPasswordHashAsync(
ApplicationUser user, ApplicationUser user,
CancellationToken cancellationToken CancellationToken cancellationToken
) => Task.FromResult(user.PasswordHash); ) => Task.FromResult(user.PasswordHash);
/// <inheritdoc />
public Task<bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) => public Task<bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) =>
Task.FromResult(user.PasswordHash != null); Task.FromResult(user.PasswordHash != null);
/// <inheritdoc />
public Task SetEmailAsync( public Task SetEmailAsync(
ApplicationUser user, ApplicationUser user,
string? email, string? email,
@@ -246,14 +351,17 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public Task<string?> GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) => public Task<string?> GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) =>
Task.FromResult<string?>(user.Email); Task.FromResult<string?>(user.Email);
/// <inheritdoc />
public Task<bool> GetEmailConfirmedAsync( public Task<bool> GetEmailConfirmedAsync(
ApplicationUser user, ApplicationUser user,
CancellationToken cancellationToken CancellationToken cancellationToken
) => Task.FromResult(user.EmailConfirmed); ) => Task.FromResult(user.EmailConfirmed);
/// <inheritdoc />
public Task SetEmailConfirmedAsync( public Task SetEmailConfirmedAsync(
ApplicationUser user, ApplicationUser user,
bool confirmed, bool confirmed,
@@ -264,6 +372,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
public async Task<ApplicationUser?> FindByEmailAsync( public async Task<ApplicationUser?> FindByEmailAsync(
string normalizedEmail, string normalizedEmail,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -284,11 +393,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
return user; return user;
} }
/// <inheritdoc />
public Task<string?> GetNormalizedEmailAsync( public Task<string?> GetNormalizedEmailAsync(
ApplicationUser user, ApplicationUser user,
CancellationToken cancellationToken CancellationToken cancellationToken
) => Task.FromResult(user.NormalizedEmail); ) => Task.FromResult(user.NormalizedEmail);
/// <inheritdoc />
public Task SetNormalizedEmailAsync( public Task SetNormalizedEmailAsync(
ApplicationUser user, ApplicationUser user,
string? normalizedEmail, string? normalizedEmail,
@@ -376,6 +487,23 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
} }
} }
/// <summary>Removes the <c>dbo.UserVerification</c> row for a user, if one exists.</summary>
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
)
);
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary> /// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
private static bool IsDuplicateKeyViolation(SqlException ex) => ex.Number is 2601 or 2627; private static bool IsDuplicateKeyViolation(SqlException ex) => ex.Number is 2601 or 2627;
/// <summary>Determines whether a <see cref="SqlException" /> is a foreign key constraint violation (SQL Server error 547).</summary>
private static bool IsForeignKeyViolation(SqlException ex) => ex.Number == 547;
} }

View File

@@ -0,0 +1,23 @@
using Domain.Entities;
namespace Features.Auth.Identity;
/// <summary>
/// Converts the persistence-agnostic <see cref="UserAccount" /> domain entity into the
/// <see cref="ApplicationUser" /> shape required by <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" />.
/// Kept here, in Features.Auth, rather than on <see cref="UserAccount" /> itself so the domain layer has
/// no dependency on ASP.NET Core Identity.
/// </summary>
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,
};
}