mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add self-service account management commands (delete, update username/email/password/profile)
This commit is contained in:
@@ -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<ResponseBody<object>> 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<object>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -602,12 +602,9 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
string? token = scenario.TryGetValue<string>("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();
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using MediatR;
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
|
||||
/// request body of <c>POST /api/auth/refresh</c>.
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the
|
||||
/// <c>X-Refresh-Token</c> header of <c>POST /api/auth/refresh</c>.
|
||||
/// </summary>
|
||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// 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")]
|
||||
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>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
/// <remarks>
|
||||
/// 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 <c>X-Refresh-Token</c> 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.
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("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(
|
||||
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." });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,48 @@ public record RegistrationPayload(
|
||||
|
||||
/// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
|
||||
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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>The current Argon2 hash, or <see langword="null" /> if the account has no password set.</summary>
|
||||
public string? PasswordHash { get; set; }
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
|
||||
@@ -3,12 +3,20 @@ using Microsoft.AspNetCore.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)
|
||||
: IPasswordHasher<ApplicationUser>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string HashPassword(ApplicationUser user, string 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(
|
||||
ApplicationUser user,
|
||||
string hashedPassword,
|
||||
|
||||
@@ -25,14 +25,18 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
FROM dbo.UserAccount
|
||||
""";
|
||||
|
||||
/// <remarks>No unmanaged resources to release; each method opens and disposes its own connection.</remarks>
|
||||
public void Dispose() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(user.Id.ToString());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<string?>(user.UserName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetUserNameAsync(
|
||||
ApplicationUser user,
|
||||
string? userName,
|
||||
@@ -43,11 +47,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetNormalizedUserNameAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.NormalizedUserName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetNormalizedUserNameAsync(
|
||||
ApplicationUser user,
|
||||
string? normalizedName,
|
||||
@@ -58,6 +64,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 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
|
||||
@@ -131,10 +138,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Persists profile field changes and, when <see cref="ApplicationUser.EmailConfirmed" /> is
|
||||
/// <see langword="true" />, ensures a corresponding <c>dbo.UserVerification</c> row exists (idempotently,
|
||||
/// matching the previous <c>AuthRepository.ConfirmUserAccountAsync</c> behavior).
|
||||
/// Persists profile field changes and keeps <c>dbo.UserVerification</c> in sync with
|
||||
/// <see cref="ApplicationUser.EmailConfirmed" />: a row is inserted (idempotently, matching the
|
||||
/// 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>
|
||||
public async Task<IdentityResult> 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<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,
|
||||
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(
|
||||
string userId,
|
||||
CancellationToken cancellationToken
|
||||
@@ -198,6 +247,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByNameAsync(
|
||||
string normalizedUserName,
|
||||
CancellationToken cancellationToken
|
||||
@@ -218,24 +268,79 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetPasswordHashAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.PasswordHash);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(user.PasswordHash != null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetEmailAsync(
|
||||
ApplicationUser user,
|
||||
string? email,
|
||||
@@ -246,14 +351,17 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<string?>(user.Email);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> GetEmailConfirmedAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.EmailConfirmed);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetEmailConfirmedAsync(
|
||||
ApplicationUser user,
|
||||
bool confirmed,
|
||||
@@ -264,6 +372,7 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByEmailAsync(
|
||||
string normalizedEmail,
|
||||
CancellationToken cancellationToken
|
||||
@@ -284,11 +393,13 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetNormalizedEmailAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.NormalizedEmail);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetNormalizedEmailAsync(
|
||||
ApplicationUser user,
|
||||
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>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user