From c621afbb7493b540f2f9a2bb633e9ae4e5782799 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Mon, 12 Jan 2026 16:25:54 +0700 Subject: [PATCH] feat(api): Enhance authentication and user management endpoints - Updated API documentation to include new user management features such as password change and logout functionalities. - Added detailed descriptions and examples for OAuth2 token endpoint, supporting password, refresh token, and client credentials grants. - Introduced new endpoints for user management, including retrieving, updating, and deleting users. - Enhanced Swagger annotations for better clarity and usability of the API documentation. - Implemented response models for password change and logout operations to standardize API responses. --- services/iam-service-net/README.md | 46 ++- services/iam-service-net/docs/vi/README.md | 265 ++++-------------- .../Commands/Auth/ChangePasswordCommand.cs | 23 ++ .../Auth/ChangePasswordCommandHandler.cs | 52 ++++ .../Commands/Auth/LogoutCommand.cs | 18 ++ .../Commands/Auth/LogoutCommandHandler.cs | 64 +++++ .../Commands/Users/DeleteUserCommand.cs | 18 ++ .../Users/DeleteUserCommandHandler.cs | 54 ++++ .../Commands/Users/UpdateUserCommand.cs | 26 ++ .../Users/UpdateUserCommandHandler.cs | 74 +++++ .../Application/Common/ApiResponse.cs | 48 ++++ .../Queries/Users/GetUserByIdQuery.cs | 25 ++ .../Queries/Users/GetUserByIdQueryHandler.cs | 46 +++ .../Controllers/AuthController.cs | 173 +++++++++++- .../Controllers/UsersController.cs | 169 +++++++++++ 15 files changed, 884 insertions(+), 217 deletions(-) create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/ChangePasswordCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/ChangePasswordCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Users/DeleteUserCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Users/DeleteUserCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUserByIdQuery.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUserByIdQueryHandler.cs diff --git a/services/iam-service-net/README.md b/services/iam-service-net/README.md index f35cf708..c807224a 100644 --- a/services/iam-service-net/README.md +++ b/services/iam-service-net/README.md @@ -55,27 +55,42 @@ dotnet run --project src/IamService.API ## API Endpoints -### Authentication +### Authentication (`/api/v1/auth`) -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/api/v1/auth/register` | Register new user | -| POST | `/connect/token` | OAuth2 token endpoint | +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| `POST` | `/api/v1/auth/register` | Register new user | ❌ | +| `POST` | `/connect/token` | OAuth2 token endpoint (login, refresh) | ❌ | +| `POST` | `/api/v1/auth/change-password` | Change password | ✅ | +| `POST` | `/api/v1/auth/logout` | Logout (revoke tokens) | ✅ | -### Token Request (Password Grant) +### User Management (`/api/v1/users`) + +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| `GET` | `/api/v1/users` | List users (paginated) | ✅ | +| `GET` | `/api/v1/users/me` | Get current user | ✅ | +| `GET` | `/api/v1/users/{id}` | Get user by ID | ✅ | +| `PUT` | `/api/v1/users/{id}` | Update user | ✅ | +| `DELETE` | `/api/v1/users/{id}` | Delete user (soft delete) | ✅ | + +### Token Request Examples + +**Password Grant (Login):** ```bash curl -X POST http://localhost:5001/connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password&username=user@example.com&password=Password123!" + -d "grant_type=password&username=user@example.com&password=Password123!&scope=openid profile email roles api" ``` -### Users (Protected) +**Refresh Token:** -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/v1/users` | List users (paginated) | -| GET | `/api/v1/users/me` | Get current user | +```bash +curl -X POST http://localhost:5001/connect/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN" +``` ### Health Checks @@ -85,6 +100,12 @@ curl -X POST http://localhost:5001/connect/token \ | `/health/live` | Liveness probe | | `/health/ready` | Readiness probe | +## Swagger UI + +After running the service, access Swagger UI at: +- **Local**: http://localhost:5001/swagger +- **Docker**: http://localhost/api/v1/iam/swagger + ## Project Structure ``` @@ -115,6 +136,7 @@ iam-service-net/ |----------|-------------|---------| | `ASPNETCORE_ENVIRONMENT` | Environment | Development | | `DATABASE_URL` | PostgreSQL connection | - | +| `JWT_SECRET` | JWT signing secret (32+ chars) | - | | `REDIS_URL` | Redis connection | - | ### Password Policy diff --git a/services/iam-service-net/docs/vi/README.md b/services/iam-service-net/docs/vi/README.md index 06697a71..309d34d2 100644 --- a/services/iam-service-net/docs/vi/README.md +++ b/services/iam-service-net/docs/vi/README.md @@ -1,19 +1,17 @@ -# Template Microservice .NET 10 +# IAM Service .NET 10 -> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture. +> **Service IAM (Identity and Access Management) .NET 10 với OAuth2/OIDC sử dụng OpenIddict.** ## Tổng Quan -Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với: +IAM Service cung cấp các chức năng quản lý danh tính và truy cập: -- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events -- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR -- **Clean Architecture** - Phân tầng Domain, Infrastructure, API -- **EF Core 10** - PostgreSQL với connection resilience -- **FluentValidation** - Validation request -- **API Versioning** - Versioning theo URL segment -- **Health Checks** - Probes sẵn sàng cho Kubernetes -- **Structured Logging** - Serilog với console và Seq +- **OAuth2/OIDC** - Authentication với OpenIddict +- **User Management** - CRUD operations cho users +- **Password Management** - Đổi mật khẩu +- **Token Management** - Issue, refresh, revoke tokens +- **CQRS Pattern** - MediatR cho Commands/Queries +- **Clean Architecture** - Domain, Infrastructure, API layers ## Yêu Cầu @@ -21,112 +19,48 @@ Template này cung cấp cấu trúc sẵn sàng production cho microservices .N |---------|-----------| | .NET SDK | 10.0.101+ | | Docker | 24.0+ | -| PostgreSQL | 15+ (hoặc dùng Docker) | - -```bash -# Kiểm tra phiên bản .NET -dotnet --version -# Kết quả nên là: 10.0.xxx -``` +| PostgreSQL | 15+ | ## Bắt Đầu Nhanh -### 1. Tạo Service Mới +### Chạy với Docker ```bash -# Sao chép template sang service mới -cp -r services/_template_dot_net services/your-service-name - -# Di chuyển đến thư mục service -cd services/your-service-name - -# Đổi tên tất cả "IamService" thành "YourService" -find . -type f -name "*.cs" -exec sed -i '' 's/IamService/YourService/g' {} + -find . -type f -name "*.csproj" -exec sed -i '' 's/IamService/YourService/g' {} + -``` - -### 2. Cấu Hình Môi Trường - -```bash -# Sao chép template môi trường -cp .env.example .env - -# Chỉnh sửa với cấu hình của bạn -nano .env -``` - -### 3. Chạy với Docker - -```bash -# Khởi động tất cả services (API + PostgreSQL + Redis) +cd deployments/local docker-compose up -d - -# Xem logs -docker-compose logs -f iamservice-api ``` -### 4. Chạy Local +### Chạy Local ```bash -# Khôi phục dependencies +cd services/iam-service-net dotnet restore - -# Build tất cả projects dotnet build - -# Chạy API dotnet run --project src/IamService.API ``` -## Cấu Trúc Dự Án +## API Endpoints -``` -_template_dot_net/ -├── src/ -│ ├── IamService.API/ # Lớp Presentation (Controllers, CQRS) -│ │ ├── Controllers/ # Các API endpoints -│ │ ├── Application/ # Triển khai CQRS -│ │ │ ├── Commands/ # Thao tác ghi (MediatR) -│ │ │ ├── Queries/ # Thao tác đọc -│ │ │ ├── Behaviors/ # MediatR pipeline behaviors -│ │ │ └── Validations/ # FluentValidation validators -│ │ ├── Middleware/ # Custom middleware -│ │ └── Program.cs # Điểm vào ứng dụng -│ │ -│ ├── IamService.Domain/ # Lớp Domain (Business logic thuần túy) -│ │ ├── AggregatesModel/ # Aggregate roots và entities -│ │ ├── Events/ # Domain events -│ │ ├── Exceptions/ # Domain exceptions -│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) -│ │ -│ └── IamService.Infrastructure/ # Lớp Infrastructure (Truy cập dữ liệu) -│ ├── EntityConfigurations/ # Cấu hình EF Core Fluent API -│ ├── Repositories/ # Triển khai repositories -│ ├── Idempotency/ # Xử lý idempotency request -│ └── IamServiceContext.cs # DbContext với Unit of Work -│ -├── tests/ -│ ├── IamService.UnitTests/ # Unit tests (Domain, Application) -│ └── IamService.FunctionalTests/ # Integration tests (API endpoints) -│ -├── Dockerfile # Multi-stage Docker build -├── docker-compose.yml # Thiết lập phát triển local -├── global.json # Pin phiên bản .NET SDK -└── Directory.Build.props # Thuộc tính MSBuild chung -``` +### Authentication (`/api/v1/auth`) -## Các Endpoint API +| Method | Endpoint | Mô Tả | Auth | +|--------|----------|-------|------| +| `POST` | `/api/v1/auth/register` | Đăng ký user mới | ❌ | +| `POST` | `/connect/token` | OAuth2 Token (login, refresh) | ❌ | +| `POST` | `/api/v1/auth/change-password` | Đổi mật khẩu | ✅ | +| `POST` | `/api/v1/auth/logout` | Đăng xuất (revoke tokens) | ✅ | -| Method | Endpoint | Mô Tả | -|--------|----------|-------| -| `GET` | `/api/v1/samples` | Lấy tất cả samples | -| `GET` | `/api/v1/samples/{id}` | Lấy sample theo ID | -| `POST` | `/api/v1/samples` | Tạo sample mới | -| `PUT` | `/api/v1/samples/{id}` | Cập nhật sample | -| `DELETE` | `/api/v1/samples/{id}` | Xóa sample | -| `PATCH` | `/api/v1/samples/{id}/status` | Thay đổi trạng thái | +### User Management (`/api/v1/users`) -### Health Endpoints +| Method | Endpoint | Mô Tả | Auth | +|--------|----------|-------|------| +| `GET` | `/api/v1/users` | Lấy danh sách users (phân trang) | ✅ | +| `GET` | `/api/v1/users/me` | Lấy thông tin user hiện tại | ✅ | +| `GET` | `/api/v1/users/{id}` | Lấy user theo ID | ✅ | +| `PUT` | `/api/v1/users/{id}` | Cập nhật thông tin user | ✅ | +| `DELETE` | `/api/v1/users/{id}` | Xóa user (soft delete) | ✅ | + +### Health Checks | Endpoint | Mục Đích | |----------|----------| @@ -134,97 +68,44 @@ _template_dot_net/ | `/health/live` | Kiểm tra sống | | `/health/ready` | Kiểm tra sẵn sàng | -## Pattern CQRS +## OAuth2 Token Endpoint -### Commands (Thao Tác Ghi) - -```csharp -// Định nghĩa command -public record CreateSampleCommand(string Name, string? Description) - : IRequest; - -// Xử lý command -public class CreateSampleCommandHandler : IRequestHandler -{ - public async Task Handle(CreateSampleCommand request, CancellationToken ct) - { - var sample = new Sample(request.Name, request.Description); - _repository.Add(sample); - await _repository.UnitOfWork.SaveEntitiesAsync(ct); - return new CreateSampleCommandResult(sample.Id); - } -} -``` - -### Queries (Thao Tác Đọc) - -```csharp -// Định nghĩa query -public record GetSampleQuery(Guid SampleId) : IRequest; -``` - -## Domain Model - -### Aggregate Root - -```csharp -public class Sample : Entity, IAggregateRoot -{ - public string Name => _name; - public SampleStatus Status => _status; - - public Sample(string name, string? description) { - // Validation business logic - if (string.IsNullOrWhiteSpace(name)) - throw new SampleDomainException("Tên sample không được để trống"); - - // Domain event - AddDomainEvent(new SampleCreatedDomainEvent(this)); - } - - public void Activate() { - if (_status != SampleStatus.Draft) - throw new SampleDomainException("Chỉ sample draft mới có thể kích hoạt"); - // Chuyển đổi trạng thái - } -} -``` - -## Kiểm Thử +### Password Grant (Login) ```bash -# Chạy tất cả tests -dotnet test - -# Chạy với coverage -dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura - -# Chạy project test cụ thể -dotnet test tests/IamService.UnitTests +curl -X POST http://localhost:5001/connect/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password&username=user@example.com&password=YourPassword&scope=openid profile email roles api" ``` +### Refresh Token + +```bash +curl -X POST http://localhost:5001/connect/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN" +``` + +## Swagger UI + +Sau khi chạy service, truy cập Swagger UI tại: +- **Local**: http://localhost:5001/swagger +- **Docker**: http://localhost/api/v1/iam/swagger + ## Cấu Hình ### Biến Môi Trường | Biến | Mô Tả | Mặc định | |------|-------|----------| -| `ASPNETCORE_ENVIRONMENT` | Tên môi trường | `Development` | -| `DATABASE_URL` | Connection string PostgreSQL | - | -| `REDIS_URL` | Connection string Redis | - | -| `JWT_SECRET` | Secret ký JWT (tối thiểu 32 ký tự) | - | +| `ASPNETCORE_ENVIRONMENT` | Môi trường | `Development` | +| `DATABASE_URL` | PostgreSQL connection | - | +| `JWT_SECRET` | Secret ký JWT (32+ ký tự) | - | -### appsettings.json +## Kiểm Thử -```json -{ - "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Database=iamservice;Username=postgres;Password=postgres" - }, - "Serilog": { - "MinimumLevel": "Information" - } -} +```bash +dotnet test ``` ## Triển Khai @@ -232,34 +113,12 @@ dotnet test tests/IamService.UnitTests ### Docker Build ```bash -# Build Docker image -docker build -t iamservice:latest . - -# Chạy container -docker run -p 5000:8080 --env-file .env iamservice:latest +docker build -t goodgo/iam-service:latest . +docker run -p 5001:8080 --env-file .env goodgo/iam-service:latest ``` -### Kubernetes - -Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết manifests triển khai Kubernetes. - -## Có Gì Mới Trong .NET 10 - -- Tính năng ngôn ngữ **C# 14** -- Hỗ trợ **Native AOT** được cải thiện -- Hiệu suất **async/await** tốt hơn -- **JSON serialization** được nâng cao -- Cải thiện hiệu suất toàn diện -- Hỗ trợ **LTS** 3 năm (đến tháng 11/2028) - ## Tài Nguyên -- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Kiến trúc tham chiếu -- [Tài liệu .NET 10](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) -- [DDD với .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) -- [MediatR](https://github.com/jbogard/MediatR) - Thư viện CQRS -- [FluentValidation](https://docs.fluentvalidation.net/) - Thư viện validation - -## Giấy Phép - -Độc quyền - GoodGo Platform +- [OpenIddict Documentation](https://documentation.openiddict.com/) +- [ASP.NET Core Identity](https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity) +- [OAuth2 Specification](https://oauth.net/2/) diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ChangePasswordCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ChangePasswordCommand.cs new file mode 100644 index 00000000..629a89de --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ChangePasswordCommand.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Command to change user password. +/// VI: Command để đổi mật khẩu user. +/// +/// User ID / ID user +/// Current password / Mật khẩu hiện tại +/// New password / Mật khẩu mới +public record ChangePasswordCommand( + Guid UserId, + string CurrentPassword, + string NewPassword) : IRequest; + +/// +/// EN: Result of ChangePasswordCommand. +/// VI: Kết quả của ChangePasswordCommand. +/// +/// Whether the operation was successful / Thao tác có thành công không +/// Result message / Thông điệp kết quả +public record ChangePasswordCommandResult(bool Success, string Message); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ChangePasswordCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ChangePasswordCommandHandler.cs new file mode 100644 index 00000000..cab51b9e --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ChangePasswordCommandHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Handler for ChangePasswordCommand. +/// VI: Handler cho ChangePasswordCommand. +/// +public class ChangePasswordCommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public ChangePasswordCommandHandler( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Changing password for user: {UserId}", request.UserId); + + 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: Validate current password and change to new password + // VI: Xác thực mật khẩu hiện tại và đổi sang mật khẩu mới + var result = await _userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogWarning("Failed to change password for user {UserId}: {Errors}", request.UserId, errors); + return new ChangePasswordCommandResult(false, $"Failed to change password: {errors}"); + } + + _logger.LogInformation("Password changed successfully for user {UserId}", request.UserId); + + return new ChangePasswordCommandResult(true, "Password changed successfully."); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommand.cs new file mode 100644 index 00000000..86087ab4 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommand.cs @@ -0,0 +1,18 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Command to logout user (revoke tokens). +/// VI: Command để logout user (thu hồi tokens). +/// +/// User ID / ID user +public record LogoutCommand(Guid UserId) : IRequest; + +/// +/// EN: Result of LogoutCommand. +/// VI: Kết quả của LogoutCommand. +/// +/// Whether the operation was successful / Thao tác có thành công không +/// Result message / Thông điệp kết quả +public record LogoutCommandResult(bool Success, string Message); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommandHandler.cs new file mode 100644 index 00000000..655a56b3 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommandHandler.cs @@ -0,0 +1,64 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using OpenIddict.Abstractions; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Handler for LogoutCommand - revokes all tokens for the user. +/// VI: Handler cho LogoutCommand - thu hồi tất cả tokens của user. +/// +public class LogoutCommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly IOpenIddictTokenManager _tokenManager; + private readonly ILogger _logger; + + public LogoutCommandHandler( + UserManager userManager, + IOpenIddictTokenManager tokenManager, + ILogger logger) + { + _userManager = userManager; + _tokenManager = tokenManager; + _logger = logger; + } + + public async Task Handle(LogoutCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Logging out user: {UserId}", request.UserId); + + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + + if (user == null) + { + _logger.LogWarning("User not found during logout: {UserId}", request.UserId); + // EN: Still return success - user doesn't exist, so effectively logged out + // VI: Vẫn trả về success - user không tồn tại, nên coi như đã logout + return new LogoutCommandResult(true, "User logged out."); + } + + try + { + // EN: Revoke all tokens for this user + // VI: Thu hồi tất cả tokens của user này + var tokens = _tokenManager.FindBySubjectAsync(request.UserId.ToString(), cancellationToken); + + await foreach (var token in tokens) + { + await _tokenManager.TryRevokeAsync(token, cancellationToken); + } + + _logger.LogInformation("All tokens revoked for user {UserId}", request.UserId); + return new LogoutCommandResult(true, "User logged out successfully. All tokens revoked."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to revoke tokens for user {UserId}", request.UserId); + // EN: Still consider it a logout even if token revocation fails + // VI: Vẫn coi như logout ngay cả khi thu hồi token thất bại + return new LogoutCommandResult(true, "User logged out. Token revocation may be pending."); + } + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Users/DeleteUserCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Users/DeleteUserCommand.cs new file mode 100644 index 00000000..b6dd9063 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Users/DeleteUserCommand.cs @@ -0,0 +1,18 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Users; + +/// +/// EN: Command to delete a user. +/// VI: Command để xóa user. +/// +/// User ID to delete / ID user cần xóa +public record DeleteUserCommand(Guid UserId) : IRequest; + +/// +/// EN: Result of DeleteUserCommand. +/// VI: Kết quả của DeleteUserCommand. +/// +/// Whether deletion was successful / Xóa có thành công không +/// Result message / Thông điệp kết quả +public record DeleteUserCommandResult(bool Success, string Message); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Users/DeleteUserCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Users/DeleteUserCommandHandler.cs new file mode 100644 index 00000000..4bd86e33 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Users/DeleteUserCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Users; + +/// +/// EN: Handler for DeleteUserCommand. +/// VI: Handler cho DeleteUserCommand. +/// +public class DeleteUserCommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public DeleteUserCommandHandler( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task Handle(DeleteUserCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Deleting user: {UserId}", request.UserId); + + 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: Soft delete - deactivate the user instead of hard delete + // VI: Soft delete - deactivate user thay vì xóa hoàn toàn + user.Disable(); + + var updateResult = await _userManager.UpdateAsync(user); + + if (!updateResult.Succeeded) + { + var errors = string.Join(", ", updateResult.Errors.Select(e => e.Description)); + _logger.LogError("Failed to delete user {UserId}: {Errors}", request.UserId, errors); + throw new DomainException($"Failed to delete user: {errors}"); + } + + _logger.LogInformation("User {UserId} deleted (deactivated) successfully", request.UserId); + + return new DeleteUserCommandResult(true, $"User {request.UserId} has been deactivated successfully."); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommand.cs new file mode 100644 index 00000000..86a1917c --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommand.cs @@ -0,0 +1,26 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Users; + +/// +/// EN: Command to update user information. +/// VI: Command để cập nhật thông tin user. +/// +/// User ID to update / ID user cần cập nhật +/// New first name / Tên mới +/// New last name / Họ mới +public record UpdateUserCommand( + Guid UserId, + string? FirstName, + string? LastName) : IRequest; + +/// +/// EN: Result of UpdateUserCommand. +/// VI: Kết quả của UpdateUserCommand. +/// +public record UpdateUserCommandResult( + Guid UserId, + string Email, + string FirstName, + string LastName, + string FullName); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommandHandler.cs new file mode 100644 index 00000000..59ee01b2 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommandHandler.cs @@ -0,0 +1,74 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.Exceptions; + +namespace IamService.API.Application.Commands.Users; + +/// +/// EN: Handler for UpdateUserCommand. +/// VI: Handler cho UpdateUserCommand. +/// +public class UpdateUserCommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public UpdateUserCommandHandler( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task Handle(UpdateUserCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("Updating user: {UserId}", request.UserId); + + 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: Update user properties + // VI: Cập nhật thuộc tính user + if (!string.IsNullOrWhiteSpace(request.FirstName)) + { + user.UpdateProfile(request.FirstName, user.LastName); + } + + if (!string.IsNullOrWhiteSpace(request.LastName)) + { + user.UpdateProfile(user.FirstName, request.LastName); + } + + // EN: If both are provided, update together + // VI: Nếu cả hai được cung cấp, cập nhật cùng lúc + if (!string.IsNullOrWhiteSpace(request.FirstName) && !string.IsNullOrWhiteSpace(request.LastName)) + { + user.UpdateProfile(request.FirstName, request.LastName); + } + + var result = await _userManager.UpdateAsync(user); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogError("Failed to update user {UserId}: {Errors}", request.UserId, errors); + throw new DomainException($"Failed to update user: {errors}"); + } + + _logger.LogInformation("User {UserId} updated successfully", request.UserId); + + return new UpdateUserCommandResult( + user.Id, + user.Email ?? string.Empty, + user.FirstName, + user.LastName, + user.FullName); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Common/ApiResponse.cs b/services/iam-service-net/src/IamService.API/Application/Common/ApiResponse.cs index 4f4d6423..ba01d007 100644 --- a/services/iam-service-net/src/IamService.API/Application/Common/ApiResponse.cs +++ b/services/iam-service-net/src/IamService.API/Application/Common/ApiResponse.cs @@ -125,3 +125,51 @@ public class PaginationInfo /// 10 public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); } + +/// +/// EN: OAuth2 Token Response (RFC 6749). +/// VI: OAuth2 Token Response (RFC 6749). +/// +public class TokenResponse +{ + /// + /// EN: The access token issued by the authorization server. + /// VI: Access token được cấp bởi authorization server. + /// + /// eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9... + public string AccessToken { get; set; } = string.Empty; + + /// + /// EN: The type of the token issued (always "Bearer"). + /// VI: Loại token được cấp (luôn là "Bearer"). + /// + /// Bearer + public string TokenType { get; set; } = "Bearer"; + + /// + /// EN: The lifetime in seconds of the access token. + /// VI: Thời gian sống (giây) của access token. + /// + /// 3600 + public int ExpiresIn { get; set; } + + /// + /// EN: The refresh token (if requested). + /// VI: Refresh token (nếu được yêu cầu). + /// + /// CfDJ8NrU3... + public string? RefreshToken { get; set; } + + /// + /// EN: The scope of the access token. + /// VI: Scope của access token. + /// + /// openid profile email roles api + public string? Scope { get; set; } + + /// + /// EN: The ID token (if openid scope requested). + /// VI: ID token (nếu openid scope được yêu cầu). + /// + public string? IdToken { get; set; } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUserByIdQuery.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUserByIdQuery.cs new file mode 100644 index 00000000..4a5f9369 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUserByIdQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.API.Application.Queries.Users; + +/// +/// EN: Query to get user by ID. +/// VI: Query để lấy user theo ID. +/// +/// User ID / ID của user +public record GetUserByIdQuery(Guid UserId) : IRequest; + +/// +/// EN: Query result containing user data. +/// VI: Kết quả query chứa dữ liệu user. +/// +public record GetUserByIdQueryResult( + Guid Id, + string Email, + string FirstName, + string LastName, + string FullName, + UserStatus Status, + DateTime CreatedAt, + DateTime? LastLoginAt); diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUserByIdQueryHandler.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUserByIdQueryHandler.cs new file mode 100644 index 00000000..f3fd6baf --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUserByIdQueryHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.API.Application.Queries.Users; + +/// +/// EN: Handler for GetUserByIdQuery. +/// VI: Handler cho GetUserByIdQuery. +/// +public class GetUserByIdQueryHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public GetUserByIdQueryHandler( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task Handle(GetUserByIdQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation("Getting user by ID: {UserId}", request.UserId); + + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + + if (user == null) + { + _logger.LogWarning("User not found: {UserId}", request.UserId); + return null; + } + + return new GetUserByIdQueryResult( + user.Id, + user.Email ?? string.Empty, + user.FirstName, + user.LastName, + user.FullName, + user.Status, + user.CreatedAt, + user.LastLoginAt); + } +} diff --git a/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs b/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs index be975634..7e4010ee 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; +using OpenIddict.Validation.AspNetCore; using Swashbuckle.AspNetCore.Annotations; using IamService.API.Application.Commands.Auth; using IamService.API.Application.Common; @@ -70,12 +71,51 @@ public class AuthController : ControllerBase /// - /// EN: OAuth2 Token endpoint (handled by OpenIddict). - /// VI: OAuth2 Token endpoint (được xử lý bởi OpenIddict). + /// EN: OAuth2 Token endpoint - supports password, refresh_token, and client_credentials grants. + /// VI: OAuth2 Token endpoint - hỗ trợ password, refresh_token, và client_credentials grants. /// + /// + /// **Password Grant (Login):** + /// ``` + /// POST /connect/token + /// Content-Type: application/x-www-form-urlencoded + /// + /// grant_type=password&username=user@example.com&password=YourPassword&scope=openid profile email roles api + /// ``` + /// + /// **Refresh Token Grant:** + /// ``` + /// POST /connect/token + /// Content-Type: application/x-www-form-urlencoded + /// + /// grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN + /// ``` + /// + /// **Client Credentials Grant:** + /// ``` + /// POST /connect/token + /// Content-Type: application/x-www-form-urlencoded + /// + /// grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&scope=api + /// ``` + /// + /// OAuth2 token response with access_token, refresh_token, expires_in [HttpPost("~/connect/token")] [Consumes("application/x-www-form-urlencoded")] [Produces("application/json")] + [SwaggerOperation( + Summary = "OAuth2 Token Endpoint", + Description = "Exchanges credentials for access tokens. Supports password, refresh_token, and client_credentials grant types.", + OperationId = "GetToken", + Tags = new[] { "Authentication" })] + [SwaggerResponse(StatusCodes.Status200OK, "Token issued successfully", typeof(TokenResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request (missing parameters)")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Invalid credentials or token")] + [SwaggerResponse(StatusCodes.Status403Forbidden, "Account locked or unsupported grant type")] + [ProducesResponseType(typeof(TokenResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Exchange() { var request = HttpContext.GetOpenIddictServerRequest() @@ -244,6 +284,76 @@ public class AuthController : ControllerBase SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)); } + /// + /// EN: Change user password. + /// VI: Đổi mật khẩu user. + /// + /// Change password request data + /// Cancellation token + /// Result of password change operation + [HttpPost("change-password")] + [Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] + [SwaggerOperation( + Summary = "Change password", + Description = "Changes the password for the currently authenticated user. Requires current password verification.", + OperationId = "ChangePassword")] + [SwaggerResponse(StatusCodes.Status200OK, "Password changed successfully", typeof(ChangePasswordResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request (current password incorrect)")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [ProducesResponseType(typeof(ChangePasswordResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task ChangePassword( + [FromBody, SwaggerRequestBody("Password change data", Required = true)] ChangePasswordRequest request, + CancellationToken cancellationToken) + { + var userIdClaim = User.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(); + } + + var command = new ChangePasswordCommand(userId, request.CurrentPassword, request.NewPassword); + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + { + return BadRequest(new ChangePasswordResponse { Success = false, Message = result.Message }); + } + + return Ok(new ChangePasswordResponse { Success = true, Message = result.Message }); + } + + /// + /// EN: Logout user and revoke tokens. + /// VI: Logout user và thu hồi tokens. + /// + /// Cancellation token + /// Result of logout operation + [HttpPost("logout")] + [Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] + [SwaggerOperation( + Summary = "Logout", + Description = "Logs out the current user and revokes all associated tokens.", + OperationId = "Logout")] + [SwaggerResponse(StatusCodes.Status200OK, "User logged out successfully", typeof(LogoutResponse))] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [ProducesResponseType(typeof(LogoutResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Logout(CancellationToken cancellationToken) + { + var userIdClaim = User.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(); + } + + var command = new LogoutCommand(userId); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(new LogoutResponse { Success = result.Success, Message = result.Message }); + } + private static IEnumerable GetDestinations(Claim claim) { switch (claim.Type) @@ -266,3 +376,62 @@ public class AuthController : ControllerBase } } } + +/// +/// EN: Request body for changing password. +/// VI: Request body để đổi mật khẩu. +/// +public class ChangePasswordRequest +{ + /// + /// EN: Current password. + /// VI: Mật khẩu hiện tại. + /// + /// OldPassword123! + public string CurrentPassword { get; set; } = string.Empty; + + /// + /// EN: New password. + /// VI: Mật khẩu mới. + /// + /// NewPassword456! + public string NewPassword { get; set; } = string.Empty; +} + +/// +/// EN: Response for change password operation. +/// VI: Response cho thao tác đổi mật khẩu. +/// +public class ChangePasswordResponse +{ + /// + /// EN: Whether the operation was successful. + /// VI: Thao tác có thành công không. + /// + public bool Success { get; set; } + + /// + /// EN: Result message. + /// VI: Thông điệp kết quả. + /// + public string Message { get; set; } = string.Empty; +} + +/// +/// EN: Response for logout operation. +/// VI: Response cho thao tác logout. +/// +public class LogoutResponse +{ + /// + /// EN: Whether the operation was successful. + /// VI: Thao tác có thành công không. + /// + public bool Success { get; set; } + + /// + /// EN: Result message. + /// VI: Thông điệp kết quả. + /// + public string Message { get; set; } = string.Empty; +} diff --git a/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs b/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs index e16f6917..36c57dc6 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using OpenIddict.Validation.AspNetCore; using Swashbuckle.AspNetCore.Annotations; using IamService.API.Application.Common; +using IamService.API.Application.Commands.Users; using IamService.API.Application.Queries.Users; namespace IamService.API.Controllers; @@ -79,6 +80,135 @@ public class UsersController : ControllerBase }); } + /// + /// EN: Get user by ID. + /// VI: Lấy user theo ID. + /// + /// User ID + /// Cancellation token + /// User information + [HttpGet("{id:guid}")] + [SwaggerOperation( + Summary = "Get user by ID", + Description = "Retrieves a specific user by their unique identifier.", + OperationId = "GetUserById")] + [SwaggerResponse(StatusCodes.Status200OK, "Successfully retrieved user", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status404NotFound, "User not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetUserById( + [FromRoute, SwaggerParameter("User ID", Required = true)] Guid id, + CancellationToken cancellationToken = default) + { + var query = new GetUserByIdQuery(id); + var result = await _mediator.Send(query, cancellationToken); + + if (result == null) + { + return NotFound(ApiResponse.Fail("USER_NOT_FOUND", $"User with ID {id} not found.")); + } + + return Ok(ApiResponse.Ok(new UserDto + { + Id = result.Id, + Email = result.Email, + FirstName = result.FirstName, + LastName = result.LastName, + FullName = result.FullName, + Status = result.Status.Name, + CreatedAt = result.CreatedAt, + LastLoginAt = result.LastLoginAt + })); + } + + /// + /// EN: Update user information. + /// VI: Cập nhật thông tin user. + /// + /// User ID to update + /// Update data + /// Cancellation token + /// Updated user information + [HttpPut("{id:guid}")] + [SwaggerOperation( + Summary = "Update user", + Description = "Updates a user's information (first name, last name).", + OperationId = "UpdateUser")] + [SwaggerResponse(StatusCodes.Status200OK, "User updated successfully", typeof(ApiResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request data")] + [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 UpdateUser( + [FromRoute, SwaggerParameter("User ID to update", Required = true)] Guid id, + [FromBody, SwaggerRequestBody("User update data", Required = true)] UpdateUserRequest request, + CancellationToken cancellationToken = default) + { + var command = new UpdateUserCommand(id, request.FirstName, request.LastName); + + try + { + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(new UserDto + { + Id = result.UserId, + Email = result.Email, + FirstName = result.FirstName, + LastName = result.LastName, + FullName = result.FullName + })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("USER_NOT_FOUND", ex.Message)); + } + } + + /// + /// EN: Delete (deactivate) a user. + /// VI: Xóa (vô hiệu hóa) user. + /// + /// User ID to delete + /// Cancellation token + /// Deletion result + [HttpDelete("{id:guid}")] + [SwaggerOperation( + Summary = "Delete user", + Description = "Soft deletes (deactivates) a user. The user data is retained but marked as inactive.", + OperationId = "DeleteUser")] + [SwaggerResponse(StatusCodes.Status200OK, "User deleted successfully")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status404NotFound, "User not found")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteUser( + [FromRoute, SwaggerParameter("User ID to delete", Required = true)] Guid id, + CancellationToken cancellationToken = default) + { + var command = new DeleteUserCommand(id); + + try + { + var result = await _mediator.Send(command, cancellationToken); + return Ok(ApiResponse.Ok(new DeleteUserResult + { + Success = result.Success, + Message = result.Message + })); + } + catch (Exception ex) when (ex.Message.Contains("not found")) + { + return NotFound(ApiResponse.Fail("USER_NOT_FOUND", ex.Message)); + } + } + /// /// EN: Get current user info. /// VI: Lấy thông tin user hiện tại. @@ -110,3 +240,42 @@ public class UsersController : ControllerBase } } +/// +/// EN: Request body for updating user. +/// VI: Request body để cập nhật user. +/// +public class UpdateUserRequest +{ + /// + /// EN: New first name. + /// VI: Tên mới. + /// + /// John + public string? FirstName { get; set; } + + /// + /// EN: New last name. + /// VI: Họ mới. + /// + /// Doe + public string? LastName { get; set; } +} + +/// +/// EN: Result of delete user operation. +/// VI: Kết quả của thao tác xóa user. +/// +public class DeleteUserResult +{ + /// + /// EN: Whether the operation was successful. + /// VI: Thao tác có thành công không. + /// + public bool Success { get; set; } + + /// + /// EN: Result message. + /// VI: Thông điệp kết quả. + /// + public string Message { get; set; } = string.Empty; +}