From eb5cb28d9f71aa7cec6d60146cdea1a94a920a31 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Mon, 12 Jan 2026 20:04:38 +0700 Subject: [PATCH] feat(exceptions): Introduce custom exceptions for better error handling and validation - Added custom exceptions: DuplicateResourceException, EntityNotFoundException, AuthenticationFailedException, and BusinessRuleException to improve error handling in the application. - Updated Program.cs to map these exceptions to appropriate HTTP status codes and problem details for better client feedback. - Refactored RegisterUserCommandHandler to throw DuplicateResourceException when a user with the same email already exists. - Enhanced testing setup in CustomWebApplicationFactory to ensure proper handling of these exceptions during functional tests. --- .../Auth/RegisterUserCommandHandler.cs | 2 +- .../Commands/Roles/AssignRoleToUserCommand.cs | 13 + .../Roles/AssignRoleToUserCommandHandler.cs | 77 +++ .../Commands/Roles/CreateRoleCommand.cs | 23 + .../Roles/CreateRoleCommandHandler.cs | 59 +++ .../Commands/Roles/DeleteRoleCommand.cs | 10 + .../Roles/DeleteRoleCommandHandler.cs | 58 +++ .../Roles/RemoveRoleFromUserCommand.cs | 13 + .../Roles/RemoveRoleFromUserCommandHandler.cs | 64 +++ .../Commands/Roles/UpdateRoleCommand.cs | 24 + .../Roles/UpdateRoleCommandHandler.cs | 76 +++ .../Queries/Roles/GetRoleByIdQuery.cs | 10 + .../Queries/Roles/GetRoleByIdQueryHandler.cs | 43 ++ .../Queries/Roles/GetRolesQuery.cs | 34 ++ .../Queries/Roles/GetRolesQueryHandler.cs | 51 ++ .../Controllers/AuthorizationController.cs | 246 ++++++++++ .../Controllers/RolesController.cs | 439 ++++++++++++++++++ .../ValidationExceptionHandler.cs | 58 +++ .../src/IamService.API/Program.cs | 64 +++ .../Exceptions/DomainException.cs | 61 +++ .../Data/OpenIddictClientSeeder.cs | 189 ++++++++ .../DependencyInjection.cs | 26 +- .../Controllers/AuthControllerTests.cs | 14 + .../CustomWebApplicationFactory.cs | 36 +- 24 files changed, 1679 insertions(+), 11 deletions(-) create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Roles/AssignRoleToUserCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Roles/AssignRoleToUserCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Roles/CreateRoleCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Roles/CreateRoleCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Roles/DeleteRoleCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Roles/DeleteRoleCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Roles/RemoveRoleFromUserCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Roles/RemoveRoleFromUserCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Roles/UpdateRoleCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Roles/UpdateRoleCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRoleByIdQuery.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRoleByIdQueryHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRolesQuery.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRolesQueryHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Controllers/AuthorizationController.cs create mode 100644 services/iam-service-net/src/IamService.API/Controllers/RolesController.cs create mode 100644 services/iam-service-net/src/IamService.API/Infrastructure/ExceptionHandlers/ValidationExceptionHandler.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Data/OpenIddictClientSeeder.cs diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommandHandler.cs index 7caea26e..de311a8e 100644 --- a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommandHandler.cs +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommandHandler.cs @@ -37,7 +37,7 @@ public class RegisterUserCommandHandler : IRequestHandler +/// EN: Command to assign a role to a user. +/// VI: Command để gán role cho user. +/// +/// User ID / ID của user +/// Role name to assign / Tên role cần gán +public record AssignRoleToUserCommand( + Guid UserId, + string RoleName) : IRequest; diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Roles/AssignRoleToUserCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/AssignRoleToUserCommandHandler.cs new file mode 100644 index 00000000..fb7dfb74 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/AssignRoleToUserCommandHandler.cs @@ -0,0 +1,77 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.AggregatesModel.RoleAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Roles; + +/// +/// EN: Handler for AssignRoleToUserCommand. +/// VI: Handler cho AssignRoleToUserCommand. +/// +public class AssignRoleToUserCommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + private readonly ILogger _logger; + + public AssignRoleToUserCommandHandler( + UserManager userManager, + RoleManager roleManager, + ILogger logger) + { + _userManager = userManager; + _roleManager = roleManager; + _logger = logger; + } + + public async Task Handle(AssignRoleToUserCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Assigning role {RoleName} to user {UserId}", request.RoleName, request.UserId); + + // EN: Find user + // VI: Tìm user + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user == null) + { + _logger.LogWarning("User not found: {UserId}", request.UserId); + throw new DomainException($"User with ID {request.UserId} not found."); + } + + // EN: Check if role exists + // VI: Kiểm tra role có tồn tại không + var roleExists = await _roleManager.RoleExistsAsync(request.RoleName); + if (!roleExists) + { + _logger.LogWarning("Role not found: {RoleName}", request.RoleName); + throw new DomainException($"Role '{request.RoleName}' not found."); + } + + // EN: Check if user already has this role + // VI: Kiểm tra user đã có role này chưa + var isInRole = await _userManager.IsInRoleAsync(user, request.RoleName); + if (isInRole) + { + _logger.LogWarning("User {UserId} already has role {RoleName}", request.UserId, request.RoleName); + throw new DomainException($"User already has role '{request.RoleName}'."); + } + + // EN: Add role to user + // VI: Thêm role cho user + var result = await _userManager.AddToRoleAsync(user, request.RoleName); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogError("Failed to assign role {RoleName} to user {UserId}: {Errors}", + request.RoleName, request.UserId, errors); + throw new DomainException($"Failed to assign role: {errors}"); + } + + _logger.LogInformation("Role {RoleName} assigned to user {UserId} successfully", + request.RoleName, request.UserId); + + return true; + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Roles/CreateRoleCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/CreateRoleCommand.cs new file mode 100644 index 00000000..a7029502 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/CreateRoleCommand.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Roles; + +/// +/// EN: Command to create a new role. +/// VI: Command để tạo role mới. +/// +/// Role name / Tên role +/// Role description / Mô tả role +public record CreateRoleCommand( + string Name, + string? Description) : IRequest; + +/// +/// EN: Result of CreateRoleCommand. +/// VI: Kết quả của CreateRoleCommand. +/// +public record CreateRoleCommandResult( + Guid Id, + string Name, + string? Description, + DateTime CreatedAt); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Roles/CreateRoleCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/CreateRoleCommandHandler.cs new file mode 100644 index 00000000..f44270fc --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/CreateRoleCommandHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using IamService.Domain.AggregatesModel.RoleAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Roles; + +/// +/// EN: Handler for CreateRoleCommand. +/// VI: Handler cho CreateRoleCommand. +/// +public class CreateRoleCommandHandler : IRequestHandler +{ + private readonly RoleManager _roleManager; + private readonly ILogger _logger; + + public CreateRoleCommandHandler( + RoleManager roleManager, + ILogger logger) + { + _roleManager = roleManager; + _logger = logger; + } + + public async Task Handle(CreateRoleCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Creating role: {RoleName}", request.Name); + + // EN: Check if role already exists + // VI: Kiểm tra role đã tồn tại chưa + var existingRole = await _roleManager.FindByNameAsync(request.Name); + if (existingRole != null) + { + _logger.LogWarning("Role already exists: {RoleName}", request.Name); + throw new DomainException($"Role with name '{request.Name}' already exists."); + } + + // EN: Create new role + // VI: Tạo role mới + var role = new ApplicationRole(request.Name, request.Description); + + var result = await _roleManager.CreateAsync(role); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogError("Failed to create role {RoleName}: {Errors}", request.Name, errors); + throw new DomainException($"Failed to create role: {errors}"); + } + + _logger.LogInformation("Role {RoleName} created successfully with ID {RoleId}", request.Name, role.Id); + + return new CreateRoleCommandResult( + role.Id, + role.Name!, + role.Description, + role.CreatedAt); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Roles/DeleteRoleCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/DeleteRoleCommand.cs new file mode 100644 index 00000000..50515bd4 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/DeleteRoleCommand.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Roles; + +/// +/// EN: Command to delete a role. +/// VI: Command để xóa role. +/// +/// Role ID to delete / ID role cần xóa +public record DeleteRoleCommand(Guid RoleId) : IRequest; diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Roles/DeleteRoleCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/DeleteRoleCommandHandler.cs new file mode 100644 index 00000000..55500824 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/DeleteRoleCommandHandler.cs @@ -0,0 +1,58 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using IamService.Domain.AggregatesModel.RoleAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Roles; + +/// +/// EN: Handler for DeleteRoleCommand. +/// VI: Handler cho DeleteRoleCommand. +/// +public class DeleteRoleCommandHandler : IRequestHandler +{ + private readonly RoleManager _roleManager; + private readonly ILogger _logger; + + public DeleteRoleCommandHandler( + RoleManager roleManager, + ILogger logger) + { + _roleManager = roleManager; + _logger = logger; + } + + public async Task Handle(DeleteRoleCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Deleting role: {RoleId}", request.RoleId); + + var role = await _roleManager.FindByIdAsync(request.RoleId.ToString()); + + if (role == null) + { + _logger.LogWarning("Role not found: {RoleId}", request.RoleId); + throw new DomainException($"Role with ID {request.RoleId} not found."); + } + + // EN: Check if role is a system role + // VI: Kiểm tra role có phải system role không + if (role.IsSystemRole) + { + _logger.LogWarning("Cannot delete system role: {RoleId}", request.RoleId); + throw new DomainException("Cannot delete system-defined roles."); + } + + var result = await _roleManager.DeleteAsync(role); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogError("Failed to delete role {RoleId}: {Errors}", request.RoleId, errors); + throw new DomainException($"Failed to delete role: {errors}"); + } + + _logger.LogInformation("Role {RoleId} deleted successfully", request.RoleId); + + return true; + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Roles/RemoveRoleFromUserCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/RemoveRoleFromUserCommand.cs new file mode 100644 index 00000000..93ac20fb --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/RemoveRoleFromUserCommand.cs @@ -0,0 +1,13 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Roles; + +/// +/// EN: Command to remove a role from a user. +/// VI: Command để xóa role khỏi user. +/// +/// User ID / ID của user +/// Role name to remove / Tên role cần xóa +public record RemoveRoleFromUserCommand( + Guid UserId, + string RoleName) : IRequest; diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Roles/RemoveRoleFromUserCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/RemoveRoleFromUserCommandHandler.cs new file mode 100644 index 00000000..2d8ce39e --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/RemoveRoleFromUserCommandHandler.cs @@ -0,0 +1,64 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Roles; + +/// +/// EN: Handler for RemoveRoleFromUserCommand. +/// VI: Handler cho RemoveRoleFromUserCommand. +/// +public class RemoveRoleFromUserCommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public RemoveRoleFromUserCommandHandler( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task Handle(RemoveRoleFromUserCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Removing role {RoleName} from user {UserId}", request.RoleName, request.UserId); + + // EN: Find user + // VI: Tìm user + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user == null) + { + _logger.LogWarning("User not found: {UserId}", request.UserId); + throw new DomainException($"User with ID {request.UserId} not found."); + } + + // EN: Check if user has this role + // VI: Kiểm tra user có role này không + var isInRole = await _userManager.IsInRoleAsync(user, request.RoleName); + if (!isInRole) + { + _logger.LogWarning("User {UserId} does not have role {RoleName}", request.UserId, request.RoleName); + throw new DomainException($"User does not have role '{request.RoleName}'."); + } + + // EN: Remove role from user + // VI: Xóa role khỏi user + var result = await _userManager.RemoveFromRoleAsync(user, request.RoleName); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogError("Failed to remove role {RoleName} from user {UserId}: {Errors}", + request.RoleName, request.UserId, errors); + throw new DomainException($"Failed to remove role: {errors}"); + } + + _logger.LogInformation("Role {RoleName} removed from user {UserId} successfully", + request.RoleName, request.UserId); + + return true; + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Roles/UpdateRoleCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/UpdateRoleCommand.cs new file mode 100644 index 00000000..fb944c4f --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/UpdateRoleCommand.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Roles; + +/// +/// EN: Command to update role information. +/// VI: Command để cập nhật thông tin role. +/// +/// Role ID to update / ID role cần cập nhật +/// New role name / Tên role mới +/// New description / Mô tả mới +public record UpdateRoleCommand( + Guid RoleId, + string Name, + string? Description) : IRequest; + +/// +/// EN: Result of UpdateRoleCommand. +/// VI: Kết quả của UpdateRoleCommand. +/// +public record UpdateRoleCommandResult( + Guid Id, + string Name, + string? Description); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Roles/UpdateRoleCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/UpdateRoleCommandHandler.cs new file mode 100644 index 00000000..4d4af1f6 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Roles/UpdateRoleCommandHandler.cs @@ -0,0 +1,76 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using IamService.Domain.AggregatesModel.RoleAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Roles; + +/// +/// EN: Handler for UpdateRoleCommand. +/// VI: Handler cho UpdateRoleCommand. +/// +public class UpdateRoleCommandHandler : IRequestHandler +{ + private readonly RoleManager _roleManager; + private readonly ILogger _logger; + + public UpdateRoleCommandHandler( + RoleManager roleManager, + ILogger logger) + { + _roleManager = roleManager; + _logger = logger; + } + + public async Task Handle(UpdateRoleCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Updating role: {RoleId}", request.RoleId); + + var role = await _roleManager.FindByIdAsync(request.RoleId.ToString()); + + if (role == null) + { + _logger.LogWarning("Role not found: {RoleId}", request.RoleId); + throw new DomainException($"Role with ID {request.RoleId} not found."); + } + + // EN: Check if role is a system role + // VI: Kiểm tra role có phải system role không + if (role.IsSystemRole) + { + _logger.LogWarning("Cannot update system role: {RoleId}", request.RoleId); + throw new DomainException("Cannot update system-defined roles."); + } + + // EN: Check if new name conflicts with existing role + // VI: Kiểm tra tên mới có trùng với role khác không + if (!role.Name!.Equals(request.Name, StringComparison.OrdinalIgnoreCase)) + { + var existingRole = await _roleManager.FindByNameAsync(request.Name); + if (existingRole != null) + { + throw new DomainException($"Role with name '{request.Name}' already exists."); + } + } + + // EN: Update role + // VI: Cập nhật role + role.Update(request.Name, request.Description); + + var result = await _roleManager.UpdateAsync(role); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogError("Failed to update role {RoleId}: {Errors}", request.RoleId, errors); + throw new DomainException($"Failed to update role: {errors}"); + } + + _logger.LogInformation("Role {RoleId} updated successfully", request.RoleId); + + return new UpdateRoleCommandResult( + role.Id, + role.Name!, + role.Description); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRoleByIdQuery.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRoleByIdQuery.cs new file mode 100644 index 00000000..0660a3fd --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRoleByIdQuery.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace IamService.API.Application.Queries.Roles; + +/// +/// EN: Query to get role by ID. +/// VI: Query để lấy role theo ID. +/// +/// Role ID / ID của role +public record GetRoleByIdQuery(Guid RoleId) : IRequest; diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRoleByIdQueryHandler.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRoleByIdQueryHandler.cs new file mode 100644 index 00000000..29788a5b --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRoleByIdQueryHandler.cs @@ -0,0 +1,43 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using IamService.Domain.AggregatesModel.RoleAggregate; + +namespace IamService.API.Application.Queries.Roles; + +/// +/// EN: Handler for GetRoleByIdQuery. +/// VI: Handler cho GetRoleByIdQuery. +/// +public class GetRoleByIdQueryHandler : IRequestHandler +{ + private readonly RoleManager _roleManager; + private readonly ILogger _logger; + + public GetRoleByIdQueryHandler( + RoleManager roleManager, + ILogger logger) + { + _roleManager = roleManager; + _logger = logger; + } + + public async Task Handle(GetRoleByIdQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation("Getting role by ID: {RoleId}", request.RoleId); + + var role = await _roleManager.FindByIdAsync(request.RoleId.ToString()); + + if (role == null) + { + _logger.LogWarning("Role not found: {RoleId}", request.RoleId); + return null; + } + + return new RoleDto( + role.Id, + role.Name!, + role.Description, + role.IsSystemRole, + role.CreatedAt); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRolesQuery.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRolesQuery.cs new file mode 100644 index 00000000..6271e4d8 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRolesQuery.cs @@ -0,0 +1,34 @@ +using MediatR; + +namespace IamService.API.Application.Queries.Roles; + +/// +/// EN: Query to get all roles with pagination. +/// VI: Query để lấy tất cả roles với phân trang. +/// +/// Page number / Số trang +/// Page size / Kích thước trang +public record GetRolesQuery( + int PageNumber = 1, + int PageSize = 10) : IRequest; + +/// +/// EN: Result of GetRolesQuery. +/// VI: Kết quả của GetRolesQuery. +/// +public record GetRolesQueryResult( + IReadOnlyList Roles, + int TotalCount, + int PageNumber, + int PageSize); + +/// +/// EN: Role DTO for query results. +/// VI: Role DTO cho kết quả query. +/// +public record RoleDto( + Guid Id, + string Name, + string? Description, + bool IsSystemRole, + DateTime CreatedAt); diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRolesQueryHandler.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRolesQueryHandler.cs new file mode 100644 index 00000000..55d2c033 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Roles/GetRolesQueryHandler.cs @@ -0,0 +1,51 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using IamService.Domain.AggregatesModel.RoleAggregate; + +namespace IamService.API.Application.Queries.Roles; + +/// +/// EN: Handler for GetRolesQuery. +/// VI: Handler cho GetRolesQuery. +/// +public class GetRolesQueryHandler : IRequestHandler +{ + private readonly RoleManager _roleManager; + private readonly ILogger _logger; + + public GetRolesQueryHandler( + RoleManager roleManager, + ILogger logger) + { + _roleManager = roleManager; + _logger = logger; + } + + public async Task Handle(GetRolesQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation("Getting roles - Page: {Page}, Size: {Size}", request.PageNumber, request.PageSize); + + var query = _roleManager.Roles; + + var totalCount = await query.CountAsync(cancellationToken); + + var roles = await query + .OrderBy(r => r.Name) + .Skip((request.PageNumber - 1) * request.PageSize) + .Take(request.PageSize) + .Select(r => new RoleDto( + r.Id, + r.Name!, + r.Description, + r.IsSystemRole, + r.CreatedAt)) + .ToListAsync(cancellationToken); + + return new GetRolesQueryResult( + roles, + totalCount, + request.PageNumber, + request.PageSize); + } +} diff --git a/services/iam-service-net/src/IamService.API/Controllers/AuthorizationController.cs b/services/iam-service-net/src/IamService.API/Controllers/AuthorizationController.cs new file mode 100644 index 00000000..89ff76de --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Controllers/AuthorizationController.cs @@ -0,0 +1,246 @@ +// EN: Authorization Controller for OAuth2 Authorization Code Flow +// VI: Authorization Controller cho OAuth2 Authorization Code Flow + +using System.Collections.Immutable; +using System.Security.Claims; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using Swashbuckle.AspNetCore.Annotations; +using IamService.Domain.AggregatesModel.UserAggregate; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace IamService.API.Controllers; + +/// +/// EN: Handles OAuth2 Authorization Code Flow with PKCE. +/// VI: Xử lý OAuth2 Authorization Code Flow với PKCE. +/// +[ApiController] +[SwaggerTag("OAuth2 Authorization endpoints - Authorization Code Flow with PKCE")] +public class AuthorizationController : ControllerBase +{ + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IOpenIddictAuthorizationManager _authorizationManager; + private readonly IOpenIddictScopeManager _scopeManager; + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public AuthorizationController( + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + IOpenIddictScopeManager scopeManager, + SignInManager signInManager, + UserManager userManager, + ILogger logger) + { + _applicationManager = applicationManager; + _authorizationManager = authorizationManager; + _scopeManager = scopeManager; + _signInManager = signInManager; + _userManager = userManager; + _logger = logger; + } + + /// + /// EN: OAuth2 Authorization endpoint - initiates the authorization flow. + /// VI: OAuth2 Authorization endpoint - khởi tạo authorization flow. + /// + /// Challenge or Authorization ticket + [HttpGet("~/connect/authorize")] + [HttpPost("~/connect/authorize")] + [IgnoreAntiforgeryToken] + [SwaggerOperation( + Summary = "OAuth2 Authorization Endpoint", + Description = "Initiates the OAuth2 Authorization Code flow with PKCE. Redirects to login if not authenticated.", + OperationId = "Authorize")] + [SwaggerResponse(StatusCodes.Status302Found, "Redirect to login or callback URL")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request parameters")] + public async Task Authorize() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + + // EN: Try to retrieve the user principal stored in the authentication cookie + // VI: Thử lấy user principal từ authentication cookie + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + + // EN: If the user is not authenticated, challenge the user + // VI: Nếu user chưa xác thực, yêu cầu đăng nhập + if (!result.Succeeded) + { + _logger.LogInformation("User not authenticated, redirecting to login"); + + // EN: Build the authorization request URL to redirect back after login + // VI: Tạo URL authorization request để redirect sau khi login + return Challenge( + authenticationSchemes: IdentityConstants.ApplicationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create( + Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) + }); + } + + // EN: Retrieve the user from the authentication result + // VI: Lấy user từ kết quả authentication + var user = await _userManager.GetUserAsync(result.Principal); + if (user == null) + { + _logger.LogWarning("User not found from authentication result"); + return Challenge( + authenticationSchemes: IdentityConstants.ApplicationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create( + Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) + }); + } + + // EN: Create the claims-based identity used in the authorization response + // VI: Tạo claims-based identity cho authorization response + var identity = new ClaimsIdentity( + authenticationType: TokenValidationParameters.DefaultAuthenticationType, + nameType: Claims.Name, + roleType: Claims.Role); + + // EN: Add standard claims + // VI: Thêm standard claims + identity.AddClaim(Claims.Subject, user.Id.ToString()); + identity.AddClaim(Claims.Name, user.UserName ?? user.Email ?? ""); + identity.AddClaim(Claims.Email, user.Email ?? ""); + identity.AddClaim(Claims.EmailVerified, (user.EmailConfirmed).ToString().ToLower()); + + if (!string.IsNullOrEmpty(user.FirstName)) + { + identity.AddClaim(Claims.GivenName, user.FirstName); + } + if (!string.IsNullOrEmpty(user.LastName)) + { + identity.AddClaim(Claims.FamilyName, user.LastName); + } + + // EN: Add roles as claims + // VI: Thêm roles như claims + var roles = await _userManager.GetRolesAsync(user); + foreach (var role in roles) + { + identity.AddClaim(Claims.Role, role); + } + + var principal = new ClaimsPrincipal(identity); + + // EN: Set requested scopes as granted + // VI: Set requested scopes là granted + var scopes = request.GetScopes(); + principal.SetScopes(scopes); + principal.SetResources(await _scopeManager.ListResourcesAsync(scopes).ToListAsync()); + + // EN: Create and store an authorization if needed + // VI: Tạo và lưu authorization nếu cần + var application = await _applicationManager.FindByClientIdAsync(request.ClientId!); + if (application != null) + { + var applicationId = await _applicationManager.GetIdAsync(application); + + // EN: Create a permanent authorization to avoid requiring consent for every request + // VI: Tạo permanent authorization để tránh yêu cầu consent cho mọi request + var authorization = await _authorizationManager.CreateAsync( + principal: principal, + subject: user.Id.ToString(), + client: applicationId!, + type: AuthorizationTypes.Permanent, + scopes: scopes); + + principal.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization)); + } + + _logger.LogInformation("Authorization granted for user {UserId} with scopes {Scopes}", + user.Id, string.Join(", ", scopes)); + + // EN: Returning a SignInResult will ask OpenIddict to issue the appropriate tokens + // VI: Returning SignInResult sẽ yêu cầu OpenIddict issue tokens phù hợp + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + /// + /// EN: OAuth2 Logout endpoint - ends the user session. + /// VI: OAuth2 Logout endpoint - kết thúc user session. + /// + [HttpGet("~/connect/logout")] + [HttpPost("~/connect/logout")] + [SwaggerOperation( + Summary = "OAuth2 Logout Endpoint", + Description = "Logs out the user and optionally redirects to post_logout_redirect_uri.", + OperationId = "Logout")] + [SwaggerResponse(StatusCodes.Status200OK, "Logged out successfully")] + [SwaggerResponse(StatusCodes.Status302Found, "Redirect to post logout URI")] + public async Task Logout() + { + var request = HttpContext.GetOpenIddictServerRequest(); + + // EN: Sign out from Identity + // VI: Đăng xuất khỏi Identity + await _signInManager.SignOutAsync(); + + _logger.LogInformation("User logged out via OAuth2 logout endpoint"); + + // EN: If post_logout_redirect_uri was provided, redirect there + // VI: Nếu có post_logout_redirect_uri, redirect đến đó + if (request?.PostLogoutRedirectUri != null) + { + return Redirect(request.PostLogoutRedirectUri); + } + + // EN: Return sign out result + // VI: Return kết quả sign out + return SignOut( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties + { + RedirectUri = "/" + }); + } + + /// + /// EN: Validates that the current user can be authenticated. + /// VI: Xác thực rằng user hiện tại có thể được xác thực. + /// + [HttpGet("~/connect/authorize/callback")] + [Authorize] + [SwaggerOperation( + Summary = "Authorization Callback", + Description = "Callback endpoint after successful authentication.", + OperationId = "AuthorizeCallback")] + [SwaggerResponse(StatusCodes.Status302Found, "Redirect back to authorization")] + public IActionResult Callback() + { + // EN: Redirect back to the authorization endpoint + // VI: Redirect về authorization endpoint + var returnUrl = Request.Query["returnUrl"].FirstOrDefault(); + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + { + return Redirect(returnUrl); + } + + return Redirect("/connect/authorize" + Request.QueryString); + } +} + +/// +/// EN: Extension methods for ClaimsIdentity +/// VI: Extension methods cho ClaimsIdentity +/// +internal static class ClaimsIdentityExtensions +{ + public static void AddClaim(this ClaimsIdentity identity, string type, string value) + { + identity.AddClaim(new Claim(type, value)); + } +} diff --git a/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs b/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs new file mode 100644 index 00000000..51af8790 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs @@ -0,0 +1,439 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Validation.AspNetCore; +using Swashbuckle.AspNetCore.Annotations; +using IamService.API.Application.Common; +using IamService.API.Application.Commands.Roles; +using IamService.API.Application.Queries.Roles; + +namespace IamService.API.Controllers; + +/// +/// EN: Roles management controller. +/// VI: Controller quản lý roles. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/roles")] +[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] +[SwaggerTag("Role management endpoints - requires authentication")] +public class RolesController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public RolesController( + IMediator mediator, + ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get all roles with pagination. + /// VI: Lấy tất cả roles với phân trang. + /// + /// Page number (1-based) + /// Number of items per page + /// Cancellation token + /// Paginated list of roles + [HttpGet] + [SwaggerOperation( + Summary = "Get all roles", + Description = "Retrieves a paginated list of all roles. Requires authentication.", + OperationId = "GetRoles")] + [SwaggerResponse(StatusCodes.Status200OK, "Successfully retrieved roles", typeof(ApiResponse>))] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetRoles( + [FromQuery, SwaggerParameter("Page number (1-based)", Required = false)] int pageNumber = 1, + [FromQuery, SwaggerParameter("Number of items per page", Required = false)] int pageSize = 10, + CancellationToken cancellationToken = default) + { + var query = new GetRolesQuery(pageNumber, pageSize); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new ApiResponse> + { + Success = true, + Data = result.Roles.Select(r => new RoleResponse + { + Id = r.Id, + Name = r.Name, + Description = r.Description, + IsSystemRole = r.IsSystemRole, + CreatedAt = r.CreatedAt + }), + Pagination = new PaginationInfo + { + PageNumber = result.PageNumber, + PageSize = result.PageSize, + TotalCount = result.TotalCount + } + }); + } + + /// + /// EN: Get role by ID. + /// VI: Lấy role theo ID. + /// + /// Role ID + /// Cancellation token + /// Role information + [HttpGet("{id:guid}")] + [SwaggerOperation( + Summary = "Get role by ID", + Description = "Retrieves a specific role by its unique identifier.", + OperationId = "GetRoleById")] + [SwaggerResponse(StatusCodes.Status200OK, "Successfully retrieved role", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Role not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetRoleById( + [FromRoute, SwaggerParameter("Role ID", Required = true)] Guid id, + CancellationToken cancellationToken = default) + { + var query = new GetRoleByIdQuery(id); + var result = await _mediator.Send(query, cancellationToken); + + if (result == null) + { + return NotFound(ApiResponse.Fail("ROLE_NOT_FOUND", $"Role with ID {id} not found.")); + } + + return Ok(ApiResponse.Ok(new RoleResponse + { + Id = result.Id, + Name = result.Name, + Description = result.Description, + IsSystemRole = result.IsSystemRole, + CreatedAt = result.CreatedAt + })); + } + + /// + /// EN: Create a new role. + /// VI: Tạo role mới. + /// + /// Role creation data + /// Cancellation token + /// Created role + [HttpPost] + [SwaggerOperation( + Summary = "Create role", + Description = "Creates a new role with the specified name and description.", + OperationId = "CreateRole")] + [SwaggerResponse(StatusCodes.Status201Created, "Role created successfully", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request data")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status409Conflict, "Role already exists")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreateRole( + [FromBody, SwaggerRequestBody("Role creation data", Required = true)] CreateRoleRequest request, + CancellationToken cancellationToken = default) + { + var command = new CreateRoleCommand(request.Name, request.Description); + + try + { + var result = await _mediator.Send(command, cancellationToken); + + var response = new RoleResponse + { + Id = result.Id, + Name = result.Name, + Description = result.Description, + IsSystemRole = false, + CreatedAt = result.CreatedAt + }; + + return CreatedAtAction(nameof(GetRoleById), new { id = result.Id }, ApiResponse.Ok(response)); + } + catch (Exception ex) when (ex.Message.Contains("already exists")) + { + return Conflict(ApiResponse.Fail("ROLE_EXISTS", ex.Message)); + } + } + + /// + /// EN: Update role information. + /// VI: Cập nhật thông tin role. + /// + /// Role ID to update + /// Update data + /// Cancellation token + /// Updated role information + [HttpPut("{id:guid}")] + [SwaggerOperation( + Summary = "Update role", + Description = "Updates a role's name and description. System roles cannot be updated.", + OperationId = "UpdateRole")] + [SwaggerResponse(StatusCodes.Status200OK, "Role updated successfully", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request data or cannot update system role")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Role not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateRole( + [FromRoute, SwaggerParameter("Role ID to update", Required = true)] Guid id, + [FromBody, SwaggerRequestBody("Role update data", Required = true)] UpdateRoleRequest request, + CancellationToken cancellationToken = default) + { + var command = new UpdateRoleCommand(id, request.Name, request.Description); + + try + { + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(new RoleResponse + { + Id = result.Id, + Name = result.Name, + Description = result.Description + })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("ROLE_NOT_FOUND", ex.Message)); + } + catch (Exception ex) when (ex.Message.Contains("system")) + { + return BadRequest(ApiResponse.Fail("SYSTEM_ROLE", ex.Message)); + } + } + + /// + /// EN: Delete a role. + /// VI: Xóa role. + /// + /// Role ID to delete + /// Cancellation token + /// Deletion result + [HttpDelete("{id:guid}")] + [SwaggerOperation( + Summary = "Delete role", + Description = "Deletes a role. System roles cannot be deleted.", + OperationId = "DeleteRole")] + [SwaggerResponse(StatusCodes.Status200OK, "Role deleted successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Cannot delete system role")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status404NotFound, "Role not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteRole( + [FromRoute, SwaggerParameter("Role ID to delete", Required = true)] Guid id, + CancellationToken cancellationToken = default) + { + var command = new DeleteRoleCommand(id); + + try + { + await _mediator.Send(command, cancellationToken); + return Ok(ApiResponse.Ok(new { Message = "Role deleted successfully." })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("ROLE_NOT_FOUND", ex.Message)); + } + catch (Exception ex) when (ex.Message.Contains("system")) + { + return BadRequest(ApiResponse.Fail("SYSTEM_ROLE", ex.Message)); + } + } + + /// + /// EN: Assign a role to a user. + /// VI: Gán role cho user. + /// + /// User ID + /// Role assignment data + /// Cancellation token + /// Assignment result + [HttpPost("/api/v{version:apiVersion}/users/{userId:guid}/roles")] + [SwaggerOperation( + Summary = "Assign role to user", + Description = "Assigns a role to a specific user.", + OperationId = "AssignRoleToUser")] + [SwaggerResponse(StatusCodes.Status200OK, "Role assigned successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request or user already has role")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status404NotFound, "User or role not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task AssignRoleToUser( + [FromRoute, SwaggerParameter("User ID", Required = true)] Guid userId, + [FromBody, SwaggerRequestBody("Role assignment data", Required = true)] AssignRoleRequest request, + CancellationToken cancellationToken = default) + { + var command = new AssignRoleToUserCommand(userId, request.RoleName); + + try + { + await _mediator.Send(command, cancellationToken); + return Ok(ApiResponse.Ok(new { Message = $"Role '{request.RoleName}' assigned to user successfully." })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("NOT_FOUND", ex.Message)); + } + catch (Exception ex) when (ex.Message.Contains("already has")) + { + return BadRequest(ApiResponse.Fail("ROLE_ALREADY_ASSIGNED", ex.Message)); + } + } + + /// + /// EN: Remove a role from a user. + /// VI: Xóa role khỏi user. + /// + /// User ID + /// Role name to remove + /// Cancellation token + /// Removal result + [HttpDelete("/api/v{version:apiVersion}/users/{userId:guid}/roles/{roleName}")] + [SwaggerOperation( + Summary = "Remove role from user", + Description = "Removes a role from a specific user.", + OperationId = "RemoveRoleFromUser")] + [SwaggerResponse(StatusCodes.Status200OK, "Role removed successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "User does not have this role")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status404NotFound, "User not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RemoveRoleFromUser( + [FromRoute, SwaggerParameter("User ID", Required = true)] Guid userId, + [FromRoute, SwaggerParameter("Role name to remove", Required = true)] string roleName, + CancellationToken cancellationToken = default) + { + var command = new RemoveRoleFromUserCommand(userId, roleName); + + try + { + await _mediator.Send(command, cancellationToken); + return Ok(ApiResponse.Ok(new { Message = $"Role '{roleName}' removed from user successfully." })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("NOT_FOUND", ex.Message)); + } + catch (Exception ex) when (ex.Message.Contains("does not have")) + { + return BadRequest(ApiResponse.Fail("ROLE_NOT_ASSIGNED", ex.Message)); + } + } +} + +#region Request/Response Models + +/// +/// EN: Request body for creating role. +/// VI: Request body để tạo role. +/// +public class CreateRoleRequest +{ + /// + /// EN: Role name. + /// VI: Tên role. + /// + /// Admin + public string Name { get; set; } = string.Empty; + + /// + /// EN: Role description. + /// VI: Mô tả role. + /// + /// Administrator with full access + public string? Description { get; set; } +} + +/// +/// EN: Request body for updating role. +/// VI: Request body để cập nhật role. +/// +public class UpdateRoleRequest +{ + /// + /// EN: New role name. + /// VI: Tên role mới. + /// + /// SuperAdmin + public string Name { get; set; } = string.Empty; + + /// + /// EN: New role description. + /// VI: Mô tả role mới. + /// + /// Super administrator with all permissions + public string? Description { get; set; } +} + +/// +/// EN: Request body for assigning role to user. +/// VI: Request body để gán role cho user. +/// +public class AssignRoleRequest +{ + /// + /// EN: Role name to assign. + /// VI: Tên role cần gán. + /// + /// Admin + public string RoleName { get; set; } = string.Empty; +} + +/// +/// EN: Role response model. +/// VI: Model response cho role. +/// +public class RoleResponse +{ + /// + /// EN: Role ID. + /// VI: ID của role. + /// + public Guid Id { get; set; } + + /// + /// EN: Role name. + /// VI: Tên role. + /// + public string Name { get; set; } = string.Empty; + + /// + /// EN: Role description. + /// VI: Mô tả role. + /// + public string? Description { get; set; } + + /// + /// EN: Whether this is a system role. + /// VI: Role có phải là system role không. + /// + public bool IsSystemRole { get; set; } + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt { get; set; } +} + +#endregion diff --git a/services/iam-service-net/src/IamService.API/Infrastructure/ExceptionHandlers/ValidationExceptionHandler.cs b/services/iam-service-net/src/IamService.API/Infrastructure/ExceptionHandlers/ValidationExceptionHandler.cs new file mode 100644 index 00000000..e61d4694 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Infrastructure/ExceptionHandlers/ValidationExceptionHandler.cs @@ -0,0 +1,58 @@ +using FluentValidation; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace IamService.API.Infrastructure.ExceptionHandlers; + +/// +/// EN: Exception handler for FluentValidation.ValidationException. +/// VI: Exception handler cho FluentValidation.ValidationException. +/// +public class ValidationExceptionHandler : IExceptionHandler +{ + private readonly ILogger _logger; + + public ValidationExceptionHandler(ILogger logger) + { + _logger = logger; + } + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + if (exception is not ValidationException validationException) + { + return false; + } + + _logger.LogWarning( + "Validation failed for request {Path} with {ErrorCount} errors / Validation thất bại cho request {Path} với {ErrorCount} lỗi", + httpContext.Request.Path, + validationException.Errors.Count()); + + var errors = validationException.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.ErrorMessage).ToArray() + ); + + var problemDetails = new ValidationProblemDetails(errors) + { + Title = "Validation Error", + Status = StatusCodes.Status400BadRequest, + Detail = "One or more validation errors occurred.", + Instance = httpContext.Request.Path, + Type = "https://httpstatuses.io/400" + }; + + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + httpContext.Response.ContentType = "application/problem+json"; + + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + + return true; + } +} diff --git a/services/iam-service-net/src/IamService.API/Program.cs b/services/iam-service-net/src/IamService.API/Program.cs index d9513a94..ab5145b2 100644 --- a/services/iam-service-net/src/IamService.API/Program.cs +++ b/services/iam-service-net/src/IamService.API/Program.cs @@ -56,6 +56,70 @@ builder.Services.AddProblemDetails(options => { options.IncludeExceptionDetails = (ctx, ex) => builder.Environment.IsDevelopment(); + + // EN: Map FluentValidation.ValidationException to 400 BadRequest + // VI: Map FluentValidation.ValidationException sang 400 BadRequest + options.Map(ex => + { + var errors = ex.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.ErrorMessage).ToArray() + ); + + return new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(errors) + { + Title = "Validation Error", + Status = StatusCodes.Status400BadRequest, + Detail = "One or more validation errors occurred.", + Type = "https://httpstatuses.io/400" + }; + }); + + // EN: Map DuplicateResourceException to 409 Conflict + // VI: Map DuplicateResourceException sang 409 Conflict + options.Map(ex => + new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Title = "Conflict", + Status = StatusCodes.Status409Conflict, + Detail = ex.Message, + Type = "https://httpstatuses.io/409" + }); + + // EN: Map EntityNotFoundException to 404 NotFound + // VI: Map EntityNotFoundException sang 404 NotFound + options.Map(ex => + new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Title = "Not Found", + Status = StatusCodes.Status404NotFound, + Detail = ex.Message, + Type = "https://httpstatuses.io/404" + }); + + // EN: Map AuthenticationFailedException to 401 Unauthorized + // VI: Map AuthenticationFailedException sang 401 Unauthorized + options.Map(ex => + new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Title = "Unauthorized", + Status = StatusCodes.Status401Unauthorized, + Detail = ex.Message, + Type = "https://httpstatuses.io/401" + }); + + // EN: Map BusinessRuleException to 422 Unprocessable Entity + // VI: Map BusinessRuleException sang 422 Unprocessable Entity + options.Map(ex => + new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Title = "Business Rule Violation", + Status = StatusCodes.Status422UnprocessableEntity, + Detail = ex.Message, + Type = "https://httpstatuses.io/422" + }); }); // EN: Add Swagger / VI: Thêm Swagger diff --git a/services/iam-service-net/src/IamService.Domain/Exceptions/DomainException.cs b/services/iam-service-net/src/IamService.Domain/Exceptions/DomainException.cs index 807b41be..134e4d3e 100644 --- a/services/iam-service-net/src/IamService.Domain/Exceptions/DomainException.cs +++ b/services/iam-service-net/src/IamService.Domain/Exceptions/DomainException.cs @@ -19,3 +19,64 @@ public class DomainException : Exception { } } + +/// +/// EN: Exception thrown when a resource already exists. +/// VI: Exception được throw khi resource đã tồn tại. +/// +public class DuplicateResourceException : DomainException +{ + public string ResourceType { get; } + public string? Identifier { get; } + + public DuplicateResourceException(string resourceType, string? identifier = null) + : base($"{resourceType}{(identifier != null ? $" with identifier '{identifier}'" : "")} already exists") + { + ResourceType = resourceType; + Identifier = identifier; + } +} + +/// +/// EN: Exception thrown when an entity is not found. +/// VI: Exception được throw khi entity không được tìm thấy. +/// +public class EntityNotFoundException : DomainException +{ + public string EntityType { get; } + public string? Identifier { get; } + + public EntityNotFoundException(string entityType, string? identifier = null) + : base($"{entityType}{(identifier != null ? $" with identifier '{identifier}'" : "")} not found") + { + EntityType = entityType; + Identifier = identifier; + } +} + +/// +/// EN: Exception thrown when authentication fails. +/// VI: Exception được throw khi authentication thất bại. +/// +public class AuthenticationFailedException : DomainException +{ + public AuthenticationFailedException(string message = "Authentication failed") + : base(message) + { + } +} + +/// +/// EN: Exception thrown when business rule validation fails. +/// VI: Exception được throw khi validation business rule thất bại. +/// +public class BusinessRuleException : DomainException +{ + public string RuleName { get; } + + public BusinessRuleException(string ruleName, string message) + : base(message) + { + RuleName = ruleName; + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Data/OpenIddictClientSeeder.cs b/services/iam-service-net/src/IamService.Infrastructure/Data/OpenIddictClientSeeder.cs new file mode 100644 index 00000000..7e354f66 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Data/OpenIddictClientSeeder.cs @@ -0,0 +1,189 @@ +// EN: OpenIddict client seeder for development +// VI: OpenIddict client seeder cho development + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace IamService.Infrastructure.Data; + +/// +/// EN: Background service to seed OpenIddict OAuth2 clients on startup. +/// VI: Background service để seed OpenIddict OAuth2 clients khi startup. +/// +public class OpenIddictClientSeeder : IHostedService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public OpenIddictClientSeeder( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = _serviceProvider.CreateAsyncScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(cancellationToken); + + var manager = scope.ServiceProvider.GetRequiredService(); + + // EN: Seed web-app client (Authorization Code + PKCE) + // VI: Seed web-app client (Authorization Code + PKCE) + await SeedWebAppClientAsync(manager, cancellationToken); + + // EN: Seed mobile-app client (Authorization Code + PKCE for native apps) + // VI: Seed mobile-app client (Authorization Code + PKCE cho native apps) + await SeedMobileAppClientAsync(manager, cancellationToken); + + // EN: Seed service client (Client Credentials) + // VI: Seed service client (Client Credentials) + await SeedServiceClientAsync(manager, cancellationToken); + + _logger.LogInformation("OpenIddict clients seeded successfully"); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private async Task SeedWebAppClientAsync( + IOpenIddictApplicationManager manager, + CancellationToken cancellationToken) + { + const string clientId = "web-app"; + + if (await manager.FindByClientIdAsync(clientId, cancellationToken) != null) + { + _logger.LogDebug("Client {ClientId} already exists, skipping", clientId); + return; + } + + await manager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = clientId, + ClientSecret = "web-app-secret", // EN: Use env variable in production / VI: Dùng env variable trong production + DisplayName = "Web Application", + ConsentType = ConsentTypes.Implicit, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Logout, + Permissions.Endpoints.Token, + Permissions.Endpoints.Revocation, + Permissions.GrantTypes.AuthorizationCode, + Permissions.GrantTypes.RefreshToken, + Permissions.ResponseTypes.Code, + Permissions.Scopes.Email, + Permissions.Scopes.Profile, + Permissions.Scopes.Roles, + Permissions.Prefixes.Scope + "api", + Permissions.Prefixes.Scope + "offline_access" + }, + RedirectUris = + { + new Uri("http://localhost:3000/auth/callback"), + new Uri("http://localhost:5173/auth/callback"), + new Uri("https://localhost:3000/auth/callback") + }, + PostLogoutRedirectUris = + { + new Uri("http://localhost:3000"), + new Uri("http://localhost:5173"), + new Uri("https://localhost:3000") + }, + Requirements = + { + Requirements.Features.ProofKeyForCodeExchange // EN: PKCE required / VI: PKCE bắt buộc + } + }, cancellationToken); + + _logger.LogInformation("Created OAuth2 client: {ClientId}", clientId); + } + + private async Task SeedMobileAppClientAsync( + IOpenIddictApplicationManager manager, + CancellationToken cancellationToken) + { + const string clientId = "mobile-app"; + + if (await manager.FindByClientIdAsync(clientId, cancellationToken) != null) + { + _logger.LogDebug("Client {ClientId} already exists, skipping", clientId); + return; + } + + await manager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = clientId, + // EN: No client secret for native/mobile apps (public client) + // VI: Không có client secret cho native/mobile apps (public client) + ClientType = ClientTypes.Public, + DisplayName = "Mobile Application", + ConsentType = ConsentTypes.Implicit, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Logout, + Permissions.Endpoints.Token, + Permissions.Endpoints.Revocation, + Permissions.GrantTypes.AuthorizationCode, + Permissions.GrantTypes.RefreshToken, + Permissions.ResponseTypes.Code, + Permissions.Scopes.Email, + Permissions.Scopes.Profile, + Permissions.Scopes.Roles, + Permissions.Prefixes.Scope + "api", + Permissions.Prefixes.Scope + "offline_access" + }, + RedirectUris = + { + // EN: Custom scheme for mobile apps + // VI: Custom scheme cho mobile apps + new Uri("com.goodgo.app://oauth/callback"), + new Uri("goodgo://auth/callback") + }, + Requirements = + { + Requirements.Features.ProofKeyForCodeExchange // EN: PKCE required / VI: PKCE bắt buộc + } + }, cancellationToken); + + _logger.LogInformation("Created OAuth2 client: {ClientId}", clientId); + } + + private async Task SeedServiceClientAsync( + IOpenIddictApplicationManager manager, + CancellationToken cancellationToken) + { + const string clientId = "service-client"; + + if (await manager.FindByClientIdAsync(clientId, cancellationToken) != null) + { + _logger.LogDebug("Client {ClientId} already exists, skipping", clientId); + return; + } + + await manager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = clientId, + ClientSecret = "service-client-secret", // EN: Use env variable in production / VI: Dùng env variable trong production + ClientType = ClientTypes.Confidential, + DisplayName = "Service to Service Client", + Permissions = + { + Permissions.Endpoints.Token, + Permissions.Endpoints.Introspection, + Permissions.GrantTypes.ClientCredentials, + Permissions.Prefixes.Scope + "api" + } + }, cancellationToken); + + _logger.LogInformation("Created OAuth2 client: {ClientId}", clientId); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs index 097dd6e1..a698fef4 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using IamService.Domain.AggregatesModel.UserAggregate; using IamService.Domain.AggregatesModel.RoleAggregate; using IamService.Domain.SeedWork; +using IamService.Infrastructure.Data; using IamService.Infrastructure.Repositories; namespace IamService.Infrastructure; @@ -102,16 +103,20 @@ public static class DependencyInjection // VI: Đăng ký OpenIddict server components .AddServer(options => { - // EN: Enable token endpoints - // VI: Bật token endpoints - options.SetTokenEndpointUris("/connect/token") + // EN: Enable Authorization Code Flow endpoints + // VI: Bật Authorization Code Flow endpoints + options.SetAuthorizationEndpointUris("/connect/authorize") + .SetLogoutEndpointUris("/connect/logout") + .SetTokenEndpointUris("/connect/token") .SetUserinfoEndpointUris("/connect/userinfo") .SetIntrospectionEndpointUris("/connect/introspect") .SetRevocationEndpointUris("/connect/revoke"); - // EN: Enable flows - // VI: Bật flows - options.AllowPasswordFlow() + // EN: Enable flows - including Authorization Code + PKCE + // VI: Bật flows - bao gồm Authorization Code + PKCE + options.AllowAuthorizationCodeFlow() + .RequireProofKeyForCodeExchange() // EN: PKCE required / VI: PKCE bắt buộc + .AllowPasswordFlow() .AllowRefreshTokenFlow() .AllowClientCredentialsFlow(); @@ -137,6 +142,8 @@ public static class DependencyInjection // EN: Configure ASP.NET Core integration // VI: Cấu hình tích hợp ASP.NET Core options.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableLogoutEndpointPassthrough() .EnableTokenEndpointPassthrough() .EnableUserinfoEndpointPassthrough() .DisableTransportSecurityRequirement(); // EN: Allow HTTP for dev / VI: Cho phép HTTP cho dev @@ -171,6 +178,13 @@ public static class DependencyInjection services.AddSingleton(); } + // EN: Register OpenIddict client seeder (skip in Testing environment) + // VI: Đăng ký OpenIddict client seeder (bỏ qua trong Testing environment) + if (!string.Equals(environmentName, "Testing", StringComparison.OrdinalIgnoreCase)) + { + services.AddHostedService(); + } + return services; } } diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs index ff7a0e81..b8ae2289 100644 --- a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs +++ b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs @@ -116,6 +116,13 @@ public class AuthControllerTests : IClassFixture // Act - Second registration with same email var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request); + // Debug: Print response body if not expected + if (response.StatusCode != HttpStatusCode.Conflict) + { + var body = await response.Content.ReadAsStringAsync(); + throw new Exception($"Expected Conflict, got {response.StatusCode}. Body: {body}"); + } + // Assert Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); } @@ -156,6 +163,13 @@ public class AuthControllerTests : IClassFixture var response = await _client.PostAsync("/connect/token", tokenRequest); + // Debug: Print response body if not expected + if (response.StatusCode != HttpStatusCode.OK) + { + var body = await response.Content.ReadAsStringAsync(); + throw new Exception($"Expected OK, got {response.StatusCode}. Body: {body}"); + } + // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs b/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs index ce2944c8..298319a7 100644 --- a/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs +++ b/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs @@ -34,12 +34,16 @@ public class CustomWebApplicationFactory : WebApplicationFactory // VI: Xóa các đăng ký liên quan đến Redis RemoveRedisRegistrations(services); + // EN: Remove OpenIddict EF Core stores to re-register with in-memory db + // VI: Xóa OpenIddict EF Core stores để đăng ký lại với in-memory db + RemoveOpenIddictStores(services); + // EN: Add mock cache service for testing // VI: Thêm mock cache service để test services.AddSingleton(); - // EN: Add in-memory database for testing BEFORE Identity/OpenIddict needs it - // VI: Thêm in-memory database để test TRƯỚC KHI Identity/OpenIddict cần nó + // EN: Add in-memory database for testing + // VI: Thêm in-memory database để test services.AddDbContext(options => { options.UseInMemoryDatabase(_databaseName); @@ -47,8 +51,17 @@ public class CustomWebApplicationFactory : WebApplicationFactory options.EnableSensitiveDataLogging(); }); - // EN: Set minimum logging level to reduce test output noise - // VI: Đặt mức logging tối thiểu để giảm nhiễu output test + // EN: Re-register OpenIddict Core with the new DbContext + // VI: Đăng ký lại OpenIddict Core với DbContext mới + services.AddOpenIddict() + .AddCore(options => + { + options.UseEntityFrameworkCore() + .UseDbContext(); + }); + + // EN: Set logging level for debugging tests + // VI: Đặt mức logging để debug tests services.AddLogging(logging => { logging.SetMinimumLevel(LogLevel.Warning); @@ -96,6 +109,21 @@ public class CustomWebApplicationFactory : WebApplicationFactory } } + private static void RemoveOpenIddictStores(IServiceCollection services) + { + // EN: Remove OpenIddict EF Core store registrations to re-register with in-memory db + // VI: Xóa các đăng ký OpenIddict EF Core stores để đăng ký lại với in-memory db + var openIddictDescriptors = services.Where(d => + d.ServiceType.FullName?.Contains("OpenIddict.EntityFrameworkCore") == true || + d.ImplementationType?.FullName?.Contains("OpenIddict.EntityFrameworkCore") == true) + .ToList(); + + foreach (var descriptor in openIddictDescriptors) + { + services.Remove(descriptor); + } + } + /// /// EN: Ensure database is created after host is built /// VI: Đảm bảo database được tạo sau khi host được build