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.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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
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>;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.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." });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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