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:
Yaojia Wang
2025-11-03 20:41:22 +01:00
parent 0e503176c4
commit 5c541ddb79
9 changed files with 107 additions and 14 deletions

View File

@@ -31,8 +31,19 @@ public class AuthController : ControllerBase
/// Login with email and password
/// </summary>
[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);
return Ok(result);
}
@@ -148,3 +159,9 @@ public class AuthController : ControllerBase
}
}
}
public record LoginRequest(
string TenantSlug,
string Email,
string Password
);

View File

@@ -64,7 +64,14 @@ public class TenantUsersController : ControllerBase
if (userTenantId != tenantId)
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);
return Ok(new { Message = "Role assigned successfully" });
}
@@ -87,7 +94,14 @@ public class TenantUsersController : ControllerBase
if (userTenantId != tenantId)
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);
return Ok(new { Message = "User removed from tenant successfully" });
}

View File

@@ -5,4 +5,5 @@ namespace ColaFlow.Modules.Identity.Application.Commands.AssignUserRole;
public record AssignUserRoleCommand(
Guid TenantId,
Guid UserId,
string Role) : IRequest<Unit>;
string Role,
Guid AssignedBy) : IRequest<Unit>;

View File

@@ -1,4 +1,5 @@
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 MediatR;
@@ -47,10 +48,12 @@ public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleComman
request.TenantId,
cancellationToken);
TenantRole? previousRole = existingRole?.Role;
if (existingRole != null)
{
// 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);
}
else
@@ -60,11 +63,22 @@ public class AssignUserRoleCommandHandler : IRequestHandler<AssignUserRoleComman
UserId.Create(request.UserId),
TenantId.Create(request.TenantId),
role,
null); // AssignedByUserId can be set from HttpContext in controller
UserId.Create(request.AssignedBy));
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;
}
}

View File

@@ -6,5 +6,7 @@ namespace ColaFlow.Modules.Identity.Application.Commands.Login;
public record LoginCommand(
string TenantSlug,
string Email,
string Password
string Password,
string? IpAddress,
string? UserAgent
) : IRequest<LoginResponseDto>;

View File

@@ -2,6 +2,7 @@ using ColaFlow.Modules.Identity.Application.Dtos;
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
using ColaFlow.Modules.Identity.Domain.Repositories;
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}");
}
// 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
var accessToken = _jwtService.GenerateToken(user, tenant, userTenantRole.Role);
// 6. Generate refresh token
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
user,
ipAddress: null,
userAgent: null,
ipAddress: request.IpAddress,
userAgent: request.UserAgent,
cancellationToken);
// 7. Update last login time
user.RecordLogin();
// 7. Save user changes (including domain event)
await _userRepository.UpdateAsync(user, cancellationToken);
// 8. Return result

View File

@@ -4,4 +4,6 @@ namespace ColaFlow.Modules.Identity.Application.Commands.RemoveUserFromTenant;
public record RemoveUserFromTenantCommand(
Guid TenantId,
Guid UserId) : IRequest<Unit>;
Guid UserId,
Guid RemovedBy,
string? Reason) : IRequest<Unit>;

View File

@@ -1,4 +1,6 @@
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 MediatR;
@@ -8,13 +10,16 @@ public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFrom
{
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly IUserRepository _userRepository;
public RemoveUserFromTenantCommandHandler(
IUserTenantRoleRepository userTenantRoleRepository,
IRefreshTokenRepository refreshTokenRepository)
IRefreshTokenRepository refreshTokenRepository,
IUserRepository userRepository)
{
_userTenantRoleRepository = userTenantRoleRepository;
_refreshTokenRepository = refreshTokenRepository;
_userRepository = userRepository;
}
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))
{
token.Revoke("User removed from tenant");
token.Revoke(request.Reason ?? "User removed from tenant");
}
if (userTokens.Any())
@@ -50,6 +55,19 @@ public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFrom
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
await _userTenantRoleRepository.DeleteAsync(userTenantRole, cancellationToken);

View File

@@ -139,6 +139,24 @@ public sealed class User : AggregateRoot
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()
{
EmailVerifiedAt = DateTime.UtcNow;