feat(backend): Activate domain events for user login, role assignment, and tenant removal
Implemented domain event raising in command handlers to enable audit logging and event-driven architecture for key Identity module operations. Changes: - Updated LoginCommand to include IpAddress and UserAgent fields for audit trail - Updated AuthController to extract and pass IP address and user agent from HTTP context - Modified LoginCommandHandler to raise UserLoggedInEvent on successful login - Updated AssignUserRoleCommand to include AssignedBy field for audit purposes - Modified AssignUserRoleCommandHandler to raise UserRoleAssignedEvent with previous role tracking - Updated RemoveUserFromTenantCommand to include RemovedBy and Reason fields - Modified RemoveUserFromTenantCommandHandler to raise UserRemovedFromTenantEvent before deletion - Added domain methods to User aggregate: RecordLoginWithEvent, RaiseRoleAssignedEvent, RaiseRemovedFromTenantEvent - Updated TenantUsersController to extract current user ID from JWT claims and pass to commands Technical Details: - All event raising follows aggregate root encapsulation pattern - Domain events are persisted through repository UpdateAsync calls - Event handlers will automatically log these events for audit trail - Maintains backward compatibility with existing login flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -31,8 +31,19 @@ public class AuthController : ControllerBase
|
|||||||
/// Login with email and password
|
/// Login with email and password
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginCommand command)
|
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||||
{
|
{
|
||||||
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
|
||||||
|
|
||||||
|
var command = new LoginCommand(
|
||||||
|
request.TenantSlug,
|
||||||
|
request.Email,
|
||||||
|
request.Password,
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
);
|
||||||
|
|
||||||
var result = await _mediator.Send(command);
|
var result = await _mediator.Send(command);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
@@ -148,3 +159,9 @@ public class AuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record LoginRequest(
|
||||||
|
string TenantSlug,
|
||||||
|
string Email,
|
||||||
|
string Password
|
||||||
|
);
|
||||||
|
|||||||
@@ -64,7 +64,14 @@ public class TenantUsersController : ControllerBase
|
|||||||
if (userTenantId != tenantId)
|
if (userTenantId != tenantId)
|
||||||
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
||||||
|
|
||||||
var command = new AssignUserRoleCommand(tenantId, userId, request.Role);
|
// Extract current user ID from claims
|
||||||
|
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
|
||||||
|
if (currentUserIdClaim == null)
|
||||||
|
return Unauthorized(new { error = "User ID not found in token" });
|
||||||
|
|
||||||
|
var currentUserId = Guid.Parse(currentUserIdClaim);
|
||||||
|
|
||||||
|
var command = new AssignUserRoleCommand(tenantId, userId, request.Role, currentUserId);
|
||||||
await _mediator.Send(command);
|
await _mediator.Send(command);
|
||||||
return Ok(new { Message = "Role assigned successfully" });
|
return Ok(new { Message = "Role assigned successfully" });
|
||||||
}
|
}
|
||||||
@@ -87,7 +94,14 @@ public class TenantUsersController : ControllerBase
|
|||||||
if (userTenantId != tenantId)
|
if (userTenantId != tenantId)
|
||||||
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" });
|
||||||
|
|
||||||
var command = new RemoveUserFromTenantCommand(tenantId, userId);
|
// Extract current user ID from claims
|
||||||
|
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
|
||||||
|
if (currentUserIdClaim == null)
|
||||||
|
return Unauthorized(new { error = "User ID not found in token" });
|
||||||
|
|
||||||
|
var currentUserId = Guid.Parse(currentUserIdClaim);
|
||||||
|
|
||||||
|
var command = new RemoveUserFromTenantCommand(tenantId, userId, currentUserId, null);
|
||||||
await _mediator.Send(command);
|
await _mediator.Send(command);
|
||||||
return Ok(new { Message = "User removed from tenant successfully" });
|
return Ok(new { Message = "User removed from tenant successfully" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ namespace ColaFlow.Modules.Identity.Application.Commands.AssignUserRole;
|
|||||||
public record AssignUserRoleCommand(
|
public record AssignUserRoleCommand(
|
||||||
Guid TenantId,
|
Guid TenantId,
|
||||||
Guid UserId,
|
Guid UserId,
|
||||||
string Role) : IRequest<Unit>;
|
string Role,
|
||||||
|
Guid AssignedBy) : IRequest<Unit>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
using ColaFlow.Modules.Identity.Domain.Repositories;
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -47,10 +48,12 @@ public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleComman
|
|||||||
request.TenantId,
|
request.TenantId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
TenantRole? previousRole = existingRole?.Role;
|
||||||
|
|
||||||
if (existingRole != null)
|
if (existingRole != null)
|
||||||
{
|
{
|
||||||
// Update existing role
|
// Update existing role
|
||||||
existingRole.UpdateRole(role, Guid.Empty); // OperatorUserId can be set from HttpContext in controller
|
existingRole.UpdateRole(role, request.AssignedBy);
|
||||||
await _userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
|
await _userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -60,11 +63,22 @@ public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleComman
|
|||||||
UserId.Create(request.UserId),
|
UserId.Create(request.UserId),
|
||||||
TenantId.Create(request.TenantId),
|
TenantId.Create(request.TenantId),
|
||||||
role,
|
role,
|
||||||
null); // AssignedByUserId can be set from HttpContext in controller
|
UserId.Create(request.AssignedBy));
|
||||||
|
|
||||||
await _userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
|
await _userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raise domain event for role assignment
|
||||||
|
user.RaiseRoleAssignedEvent(
|
||||||
|
TenantId.Create(request.TenantId),
|
||||||
|
role,
|
||||||
|
previousRole,
|
||||||
|
request.AssignedBy
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save changes to persist event
|
||||||
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
return Unit.Value;
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,7 @@ namespace ColaFlow.Modules.Identity.Application.Commands.Login;
|
|||||||
public record LoginCommand(
|
public record LoginCommand(
|
||||||
string TenantSlug,
|
string TenantSlug,
|
||||||
string Email,
|
string Email,
|
||||||
string Password
|
string Password,
|
||||||
|
string? IpAddress,
|
||||||
|
string? UserAgent
|
||||||
) : IRequest<LoginResponseDto>;
|
) : IRequest<LoginResponseDto>;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using ColaFlow.Modules.Identity.Application.Dtos;
|
|||||||
using ColaFlow.Modules.Identity.Application.Services;
|
using ColaFlow.Modules.Identity.Application.Services;
|
||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
using ColaFlow.Modules.Identity.Domain.Repositories;
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -67,18 +68,24 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
|
|||||||
throw new InvalidOperationException($"User {user.Id} has no role assigned for tenant {tenant.Id}");
|
throw new InvalidOperationException($"User {user.Id} has no role assigned for tenant {tenant.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4.5. Record login and raise domain event
|
||||||
|
user.RecordLoginWithEvent(
|
||||||
|
TenantId.Create(tenant.Id),
|
||||||
|
request.IpAddress ?? "unknown",
|
||||||
|
request.UserAgent ?? "unknown"
|
||||||
|
);
|
||||||
|
|
||||||
// 5. Generate JWT token with role
|
// 5. Generate JWT token with role
|
||||||
var accessToken = _jwtService.GenerateToken(user, tenant, userTenantRole.Role);
|
var accessToken = _jwtService.GenerateToken(user, tenant, userTenantRole.Role);
|
||||||
|
|
||||||
// 6. Generate refresh token
|
// 6. Generate refresh token
|
||||||
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
|
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
|
||||||
user,
|
user,
|
||||||
ipAddress: null,
|
ipAddress: request.IpAddress,
|
||||||
userAgent: null,
|
userAgent: request.UserAgent,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
// 7. Update last login time
|
// 7. Save user changes (including domain event)
|
||||||
user.RecordLogin();
|
|
||||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
|
||||||
// 8. Return result
|
// 8. Return result
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ namespace ColaFlow.Modules.Identity.Application.Commands.RemoveUserFromTenant;
|
|||||||
|
|
||||||
public record RemoveUserFromTenantCommand(
|
public record RemoveUserFromTenantCommand(
|
||||||
Guid TenantId,
|
Guid TenantId,
|
||||||
Guid UserId) : IRequest<Unit>;
|
Guid UserId,
|
||||||
|
Guid RemovedBy,
|
||||||
|
string? Reason) : IRequest<Unit>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
using ColaFlow.Modules.Identity.Domain.Repositories;
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -8,13 +10,16 @@ public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFrom
|
|||||||
{
|
{
|
||||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
||||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
|
||||||
public RemoveUserFromTenantCommandHandler(
|
public RemoveUserFromTenantCommandHandler(
|
||||||
IUserTenantRoleRepository userTenantRoleRepository,
|
IUserTenantRoleRepository userTenantRoleRepository,
|
||||||
IRefreshTokenRepository refreshTokenRepository)
|
IRefreshTokenRepository refreshTokenRepository,
|
||||||
|
IUserRepository userRepository)
|
||||||
{
|
{
|
||||||
_userTenantRoleRepository = userTenantRoleRepository;
|
_userTenantRoleRepository = userTenantRoleRepository;
|
||||||
_refreshTokenRepository = refreshTokenRepository;
|
_refreshTokenRepository = refreshTokenRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Unit> Handle(RemoveUserFromTenantCommand request, CancellationToken cancellationToken)
|
public async Task<Unit> Handle(RemoveUserFromTenantCommand request, CancellationToken cancellationToken)
|
||||||
@@ -42,7 +47,7 @@ public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFrom
|
|||||||
|
|
||||||
foreach (var token in userTokens.Where(t => !t.RevokedAt.HasValue))
|
foreach (var token in userTokens.Where(t => !t.RevokedAt.HasValue))
|
||||||
{
|
{
|
||||||
token.Revoke("User removed from tenant");
|
token.Revoke(request.Reason ?? "User removed from tenant");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userTokens.Any())
|
if (userTokens.Any())
|
||||||
@@ -50,6 +55,19 @@ public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFrom
|
|||||||
await _refreshTokenRepository.UpdateRangeAsync(userTokens, cancellationToken);
|
await _refreshTokenRepository.UpdateRangeAsync(userTokens, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raise domain event before deletion
|
||||||
|
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
user.RaiseRemovedFromTenantEvent(
|
||||||
|
TenantId.Create(request.TenantId),
|
||||||
|
request.RemovedBy,
|
||||||
|
request.Reason ?? "User removed from tenant"
|
||||||
|
);
|
||||||
|
|
||||||
|
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove user's role
|
// Remove user's role
|
||||||
await _userTenantRoleRepository.DeleteAsync(userTenantRole, cancellationToken);
|
await _userTenantRoleRepository.DeleteAsync(userTenantRole, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,24 @@ public sealed class User : AggregateRoot
|
|||||||
UpdatedAt = DateTime.UtcNow;
|
UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RecordLoginWithEvent(TenantId tenantId, string ipAddress, string userAgent)
|
||||||
|
{
|
||||||
|
LastLoginAt = DateTime.UtcNow;
|
||||||
|
UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
AddDomainEvent(new UserLoggedInEvent(Id, tenantId, ipAddress, userAgent));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseRoleAssignedEvent(TenantId tenantId, TenantRole role, TenantRole? previousRole, Guid assignedBy)
|
||||||
|
{
|
||||||
|
AddDomainEvent(new UserRoleAssignedEvent(Id, tenantId, role, previousRole, assignedBy));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseRemovedFromTenantEvent(TenantId tenantId, Guid removedBy, string reason)
|
||||||
|
{
|
||||||
|
AddDomainEvent(new UserRemovedFromTenantEvent(Id, tenantId, removedBy, reason));
|
||||||
|
}
|
||||||
|
|
||||||
public void VerifyEmail()
|
public void VerifyEmail()
|
||||||
{
|
{
|
||||||
EmailVerifiedAt = DateTime.UtcNow;
|
EmailVerifiedAt = DateTime.UtcNow;
|
||||||
|
|||||||
Reference in New Issue
Block a user