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.
This commit is contained in:
Ho Ngoc Hai
2026-01-12 20:04:38 +07:00
parent 74f423992b
commit eb5cb28d9f
24 changed files with 1679 additions and 11 deletions

View File

@@ -37,7 +37,7 @@ public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand, R
if (existingUser != null)
{
_logger.LogWarning("User with email {Email} already exists", request.Email);
throw new InvalidOperationException($"User with email {request.Email} already exists");
throw new Domain.Exceptions.DuplicateResourceException("User", request.Email);
}
// EN: Create new user

View File

@@ -0,0 +1,13 @@
using MediatR;
namespace IamService.API.Application.Commands.Roles;
/// <summary>
/// EN: Command to assign a role to a user.
/// VI: Command để gán role cho user.
/// </summary>
/// <param name="UserId">User ID / ID của user</param>
/// <param name="RoleName">Role name to assign / Tên role cần gán</param>
public record AssignRoleToUserCommand(
Guid UserId,
string RoleName) : IRequest<bool>;

View File

@@ -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;
/// <summary>
/// EN: Handler for AssignRoleToUserCommand.
/// VI: Handler cho AssignRoleToUserCommand.
/// </summary>
public class AssignRoleToUserCommandHandler : IRequestHandler<AssignRoleToUserCommand, bool>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly ILogger<AssignRoleToUserCommandHandler> _logger;
public AssignRoleToUserCommandHandler(
UserManager<ApplicationUser> userManager,
RoleManager<ApplicationRole> roleManager,
ILogger<AssignRoleToUserCommandHandler> logger)
{
_userManager = userManager;
_roleManager = roleManager;
_logger = logger;
}
public async Task<bool> 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;
}
}

View File

@@ -0,0 +1,23 @@
using MediatR;
namespace IamService.API.Application.Commands.Roles;
/// <summary>
/// EN: Command to create a new role.
/// VI: Command để tạo role mới.
/// </summary>
/// <param name="Name">Role name / Tên role</param>
/// <param name="Description">Role description / Mô tả role</param>
public record CreateRoleCommand(
string Name,
string? Description) : IRequest<CreateRoleCommandResult>;
/// <summary>
/// EN: Result of CreateRoleCommand.
/// VI: Kết quả của CreateRoleCommand.
/// </summary>
public record CreateRoleCommandResult(
Guid Id,
string Name,
string? Description,
DateTime CreatedAt);

View File

@@ -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;
/// <summary>
/// EN: Handler for CreateRoleCommand.
/// VI: Handler cho CreateRoleCommand.
/// </summary>
public class CreateRoleCommandHandler : IRequestHandler<CreateRoleCommand, CreateRoleCommandResult>
{
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly ILogger<CreateRoleCommandHandler> _logger;
public CreateRoleCommandHandler(
RoleManager<ApplicationRole> roleManager,
ILogger<CreateRoleCommandHandler> logger)
{
_roleManager = roleManager;
_logger = logger;
}
public async Task<CreateRoleCommandResult> 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);
}
}

View File

@@ -0,0 +1,10 @@
using MediatR;
namespace IamService.API.Application.Commands.Roles;
/// <summary>
/// EN: Command to delete a role.
/// VI: Command để xóa role.
/// </summary>
/// <param name="RoleId">Role ID to delete / ID role cần xóa</param>
public record DeleteRoleCommand(Guid RoleId) : IRequest<bool>;

View File

@@ -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;
/// <summary>
/// EN: Handler for DeleteRoleCommand.
/// VI: Handler cho DeleteRoleCommand.
/// </summary>
public class DeleteRoleCommandHandler : IRequestHandler<DeleteRoleCommand, bool>
{
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly ILogger<DeleteRoleCommandHandler> _logger;
public DeleteRoleCommandHandler(
RoleManager<ApplicationRole> roleManager,
ILogger<DeleteRoleCommandHandler> logger)
{
_roleManager = roleManager;
_logger = logger;
}
public async Task<bool> 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;
}
}

View File

@@ -0,0 +1,13 @@
using MediatR;
namespace IamService.API.Application.Commands.Roles;
/// <summary>
/// EN: Command to remove a role from a user.
/// VI: Command để xóa role khỏi user.
/// </summary>
/// <param name="UserId">User ID / ID của user</param>
/// <param name="RoleName">Role name to remove / Tên role cần xóa</param>
public record RemoveRoleFromUserCommand(
Guid UserId,
string RoleName) : IRequest<bool>;

View File

@@ -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;
/// <summary>
/// EN: Handler for RemoveRoleFromUserCommand.
/// VI: Handler cho RemoveRoleFromUserCommand.
/// </summary>
public class RemoveRoleFromUserCommandHandler : IRequestHandler<RemoveRoleFromUserCommand, bool>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<RemoveRoleFromUserCommandHandler> _logger;
public RemoveRoleFromUserCommandHandler(
UserManager<ApplicationUser> userManager,
ILogger<RemoveRoleFromUserCommandHandler> logger)
{
_userManager = userManager;
_logger = logger;
}
public async Task<bool> 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;
}
}

View File

@@ -0,0 +1,24 @@
using MediatR;
namespace IamService.API.Application.Commands.Roles;
/// <summary>
/// EN: Command to update role information.
/// VI: Command để cập nhật thông tin role.
/// </summary>
/// <param name="RoleId">Role ID to update / ID role cần cập nhật</param>
/// <param name="Name">New role name / Tên role mới</param>
/// <param name="Description">New description / Mô tả mới</param>
public record UpdateRoleCommand(
Guid RoleId,
string Name,
string? Description) : IRequest<UpdateRoleCommandResult>;
/// <summary>
/// EN: Result of UpdateRoleCommand.
/// VI: Kết quả của UpdateRoleCommand.
/// </summary>
public record UpdateRoleCommandResult(
Guid Id,
string Name,
string? Description);

View File

@@ -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;
/// <summary>
/// EN: Handler for UpdateRoleCommand.
/// VI: Handler cho UpdateRoleCommand.
/// </summary>
public class UpdateRoleCommandHandler : IRequestHandler<UpdateRoleCommand, UpdateRoleCommandResult>
{
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly ILogger<UpdateRoleCommandHandler> _logger;
public UpdateRoleCommandHandler(
RoleManager<ApplicationRole> roleManager,
ILogger<UpdateRoleCommandHandler> logger)
{
_roleManager = roleManager;
_logger = logger;
}
public async Task<UpdateRoleCommandResult> 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);
}
}

View File

@@ -0,0 +1,10 @@
using MediatR;
namespace IamService.API.Application.Queries.Roles;
/// <summary>
/// EN: Query to get role by ID.
/// VI: Query để lấy role theo ID.
/// </summary>
/// <param name="RoleId">Role ID / ID của role</param>
public record GetRoleByIdQuery(Guid RoleId) : IRequest<RoleDto?>;

View File

@@ -0,0 +1,43 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using IamService.Domain.AggregatesModel.RoleAggregate;
namespace IamService.API.Application.Queries.Roles;
/// <summary>
/// EN: Handler for GetRoleByIdQuery.
/// VI: Handler cho GetRoleByIdQuery.
/// </summary>
public class GetRoleByIdQueryHandler : IRequestHandler<GetRoleByIdQuery, RoleDto?>
{
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly ILogger<GetRoleByIdQueryHandler> _logger;
public GetRoleByIdQueryHandler(
RoleManager<ApplicationRole> roleManager,
ILogger<GetRoleByIdQueryHandler> logger)
{
_roleManager = roleManager;
_logger = logger;
}
public async Task<RoleDto?> 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);
}
}

View File

@@ -0,0 +1,34 @@
using MediatR;
namespace IamService.API.Application.Queries.Roles;
/// <summary>
/// EN: Query to get all roles with pagination.
/// VI: Query để lấy tất cả roles với phân trang.
/// </summary>
/// <param name="PageNumber">Page number / Số trang</param>
/// <param name="PageSize">Page size / Kích thước trang</param>
public record GetRolesQuery(
int PageNumber = 1,
int PageSize = 10) : IRequest<GetRolesQueryResult>;
/// <summary>
/// EN: Result of GetRolesQuery.
/// VI: Kết quả của GetRolesQuery.
/// </summary>
public record GetRolesQueryResult(
IReadOnlyList<RoleDto> Roles,
int TotalCount,
int PageNumber,
int PageSize);
/// <summary>
/// EN: Role DTO for query results.
/// VI: Role DTO cho kết quả query.
/// </summary>
public record RoleDto(
Guid Id,
string Name,
string? Description,
bool IsSystemRole,
DateTime CreatedAt);

View File

@@ -0,0 +1,51 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using IamService.Domain.AggregatesModel.RoleAggregate;
namespace IamService.API.Application.Queries.Roles;
/// <summary>
/// EN: Handler for GetRolesQuery.
/// VI: Handler cho GetRolesQuery.
/// </summary>
public class GetRolesQueryHandler : IRequestHandler<GetRolesQuery, GetRolesQueryResult>
{
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly ILogger<GetRolesQueryHandler> _logger;
public GetRolesQueryHandler(
RoleManager<ApplicationRole> roleManager,
ILogger<GetRolesQueryHandler> logger)
{
_roleManager = roleManager;
_logger = logger;
}
public async Task<GetRolesQueryResult> 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);
}
}

View File

@@ -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;
/// <summary>
/// EN: Handles OAuth2 Authorization Code Flow with PKCE.
/// VI: Xử lý OAuth2 Authorization Code Flow với PKCE.
/// </summary>
[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<ApplicationUser> _signInManager;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<AuthorizationController> _logger;
public AuthorizationController(
IOpenIddictApplicationManager applicationManager,
IOpenIddictAuthorizationManager authorizationManager,
IOpenIddictScopeManager scopeManager,
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
ILogger<AuthorizationController> logger)
{
_applicationManager = applicationManager;
_authorizationManager = authorizationManager;
_scopeManager = scopeManager;
_signInManager = signInManager;
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// EN: OAuth2 Authorization endpoint - initiates the authorization flow.
/// VI: OAuth2 Authorization endpoint - khởi tạo authorization flow.
/// </summary>
/// <returns>Challenge or Authorization ticket</returns>
[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<IActionResult> 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);
}
/// <summary>
/// EN: OAuth2 Logout endpoint - ends the user session.
/// VI: OAuth2 Logout endpoint - kết thúc user session.
/// </summary>
[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<IActionResult> 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 = "/"
});
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}
/// <summary>
/// EN: Extension methods for ClaimsIdentity
/// VI: Extension methods cho ClaimsIdentity
/// </summary>
internal static class ClaimsIdentityExtensions
{
public static void AddClaim(this ClaimsIdentity identity, string type, string value)
{
identity.AddClaim(new Claim(type, value));
}
}

View File

@@ -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;
/// <summary>
/// EN: Roles management controller.
/// VI: Controller quản lý roles.
/// </summary>
[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<RolesController> _logger;
public RolesController(
IMediator mediator,
ILogger<RolesController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all roles with pagination.
/// VI: Lấy tất cả roles với phân trang.
/// </summary>
/// <param name="pageNumber">Page number (1-based)</param>
/// <param name="pageSize">Number of items per page</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Paginated list of roles</returns>
[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<IEnumerable<RoleResponse>>))]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[ProducesResponseType(typeof(ApiResponse<IEnumerable<RoleResponse>>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> 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<IEnumerable<RoleResponse>>
{
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
}
});
}
/// <summary>
/// EN: Get role by ID.
/// VI: Lấy role theo ID.
/// </summary>
/// <param name="id">Role ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Role information</returns>
[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<RoleResponse>))]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[SwaggerResponse(StatusCodes.Status404NotFound, "Role not found")]
[ProducesResponseType(typeof(ApiResponse<RoleResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<RoleResponse>.Fail("ROLE_NOT_FOUND", $"Role with ID {id} not found."));
}
return Ok(ApiResponse<RoleResponse>.Ok(new RoleResponse
{
Id = result.Id,
Name = result.Name,
Description = result.Description,
IsSystemRole = result.IsSystemRole,
CreatedAt = result.CreatedAt
}));
}
/// <summary>
/// EN: Create a new role.
/// VI: Tạo role mới.
/// </summary>
/// <param name="request">Role creation data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Created role</returns>
[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<RoleResponse>))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request data")]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[SwaggerResponse(StatusCodes.Status409Conflict, "Role already exists")]
[ProducesResponseType(typeof(ApiResponse<RoleResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> 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<RoleResponse>.Ok(response));
}
catch (Exception ex) when (ex.Message.Contains("already exists"))
{
return Conflict(ApiResponse<RoleResponse>.Fail("ROLE_EXISTS", ex.Message));
}
}
/// <summary>
/// EN: Update role information.
/// VI: Cập nhật thông tin role.
/// </summary>
/// <param name="id">Role ID to update</param>
/// <param name="request">Update data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Updated role information</returns>
[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<RoleResponse>))]
[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<RoleResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<RoleResponse>.Ok(new RoleResponse
{
Id = result.Id,
Name = result.Name,
Description = result.Description
}));
}
catch (Exception ex) when (ex.Message.Contains("not found"))
{
return NotFound(ApiResponse<RoleResponse>.Fail("ROLE_NOT_FOUND", ex.Message));
}
catch (Exception ex) when (ex.Message.Contains("system"))
{
return BadRequest(ApiResponse<RoleResponse>.Fail("SYSTEM_ROLE", ex.Message));
}
}
/// <summary>
/// EN: Delete a role.
/// VI: Xóa role.
/// </summary>
/// <param name="id">Role ID to delete</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Deletion result</returns>
[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<object>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<object>.Ok(new { Message = "Role deleted successfully." }));
}
catch (Exception ex) when (ex.Message.Contains("not found"))
{
return NotFound(ApiResponse<object>.Fail("ROLE_NOT_FOUND", ex.Message));
}
catch (Exception ex) when (ex.Message.Contains("system"))
{
return BadRequest(ApiResponse<object>.Fail("SYSTEM_ROLE", ex.Message));
}
}
/// <summary>
/// EN: Assign a role to a user.
/// VI: Gán role cho user.
/// </summary>
/// <param name="userId">User ID</param>
/// <param name="request">Role assignment data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Assignment result</returns>
[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<object>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<object>.Ok(new { Message = $"Role '{request.RoleName}' assigned to user successfully." }));
}
catch (Exception ex) when (ex.Message.Contains("not found"))
{
return NotFound(ApiResponse<object>.Fail("NOT_FOUND", ex.Message));
}
catch (Exception ex) when (ex.Message.Contains("already has"))
{
return BadRequest(ApiResponse<object>.Fail("ROLE_ALREADY_ASSIGNED", ex.Message));
}
}
/// <summary>
/// EN: Remove a role from a user.
/// VI: Xóa role khỏi user.
/// </summary>
/// <param name="userId">User ID</param>
/// <param name="roleName">Role name to remove</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Removal result</returns>
[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<object>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<object>.Ok(new { Message = $"Role '{roleName}' removed from user successfully." }));
}
catch (Exception ex) when (ex.Message.Contains("not found"))
{
return NotFound(ApiResponse<object>.Fail("NOT_FOUND", ex.Message));
}
catch (Exception ex) when (ex.Message.Contains("does not have"))
{
return BadRequest(ApiResponse<object>.Fail("ROLE_NOT_ASSIGNED", ex.Message));
}
}
}
#region Request/Response Models
/// <summary>
/// EN: Request body for creating role.
/// VI: Request body để tạo role.
/// </summary>
public class CreateRoleRequest
{
/// <summary>
/// EN: Role name.
/// VI: Tên role.
/// </summary>
/// <example>Admin</example>
public string Name { get; set; } = string.Empty;
/// <summary>
/// EN: Role description.
/// VI: Mô tả role.
/// </summary>
/// <example>Administrator with full access</example>
public string? Description { get; set; }
}
/// <summary>
/// EN: Request body for updating role.
/// VI: Request body để cập nhật role.
/// </summary>
public class UpdateRoleRequest
{
/// <summary>
/// EN: New role name.
/// VI: Tên role mới.
/// </summary>
/// <example>SuperAdmin</example>
public string Name { get; set; } = string.Empty;
/// <summary>
/// EN: New role description.
/// VI: Mô tả role mới.
/// </summary>
/// <example>Super administrator with all permissions</example>
public string? Description { get; set; }
}
/// <summary>
/// EN: Request body for assigning role to user.
/// VI: Request body để gán role cho user.
/// </summary>
public class AssignRoleRequest
{
/// <summary>
/// EN: Role name to assign.
/// VI: Tên role cần gán.
/// </summary>
/// <example>Admin</example>
public string RoleName { get; set; } = string.Empty;
}
/// <summary>
/// EN: Role response model.
/// VI: Model response cho role.
/// </summary>
public class RoleResponse
{
/// <summary>
/// EN: Role ID.
/// VI: ID của role.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// EN: Role name.
/// VI: Tên role.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// EN: Role description.
/// VI: Mô tả role.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// EN: Whether this is a system role.
/// VI: Role có phải là system role không.
/// </summary>
public bool IsSystemRole { get; set; }
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt { get; set; }
}
#endregion

View File

@@ -0,0 +1,58 @@
using FluentValidation;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace IamService.API.Infrastructure.ExceptionHandlers;
/// <summary>
/// EN: Exception handler for FluentValidation.ValidationException.
/// VI: Exception handler cho FluentValidation.ValidationException.
/// </summary>
public class ValidationExceptionHandler : IExceptionHandler
{
private readonly ILogger<ValidationExceptionHandler> _logger;
public ValidationExceptionHandler(ILogger<ValidationExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> 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;
}
}

View File

@@ -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<FluentValidation.ValidationException>(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<IamService.Domain.Exceptions.DuplicateResourceException>(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<IamService.Domain.Exceptions.EntityNotFoundException>(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<IamService.Domain.Exceptions.AuthenticationFailedException>(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<IamService.Domain.Exceptions.BusinessRuleException>(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

View File

@@ -19,3 +19,64 @@ public class DomainException : Exception
{
}
}
/// <summary>
/// EN: Exception thrown when a resource already exists.
/// VI: Exception được throw khi resource đã tồn tại.
/// </summary>
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;
}
}
/// <summary>
/// EN: Exception thrown when an entity is not found.
/// VI: Exception được throw khi entity không được tìm thấy.
/// </summary>
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;
}
}
/// <summary>
/// EN: Exception thrown when authentication fails.
/// VI: Exception được throw khi authentication thất bại.
/// </summary>
public class AuthenticationFailedException : DomainException
{
public AuthenticationFailedException(string message = "Authentication failed")
: base(message)
{
}
}
/// <summary>
/// EN: Exception thrown when business rule validation fails.
/// VI: Exception được throw khi validation business rule thất bại.
/// </summary>
public class BusinessRuleException : DomainException
{
public string RuleName { get; }
public BusinessRuleException(string ruleName, string message)
: base(message)
{
RuleName = ruleName;
}
}

View File

@@ -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;
/// <summary>
/// EN: Background service to seed OpenIddict OAuth2 clients on startup.
/// VI: Background service để seed OpenIddict OAuth2 clients khi startup.
/// </summary>
public class OpenIddictClientSeeder : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<OpenIddictClientSeeder> _logger;
public OpenIddictClientSeeder(
IServiceProvider serviceProvider,
ILogger<OpenIddictClientSeeder> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await using var scope = _serviceProvider.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<IamServiceContext>();
await context.Database.EnsureCreatedAsync(cancellationToken);
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
// 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);
}
}

View File

@@ -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<Caching.ICacheService, Caching.RedisCacheService>();
}
// 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<OpenIddictClientSeeder>();
}
return services;
}
}

View File

@@ -116,6 +116,13 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
// 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<CustomWebApplicationFactory>
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);

View File

@@ -34,12 +34,16 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
// 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<ICacheService, InMemoryCacheService>();
// 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<IamServiceContext>(options =>
{
options.UseInMemoryDatabase(_databaseName);
@@ -47,8 +51,17 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
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<IamServiceContext>();
});
// 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<Program>
}
}
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);
}
}
/// <summary>
/// EN: Ensure database is created after host is built
/// VI: Đảm bảo database được tạo sau khi host được build