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.
This commit is contained in:
Ho Ngoc Hai
2026-01-12 16:25:54 +07:00
parent 07cb482edc
commit c621afbb74
15 changed files with 884 additions and 217 deletions

View File

@@ -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

View File

@@ -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<CreateSampleCommandResult>;
// Xử lý command
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
{
public async Task<CreateSampleCommandResult> 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<SampleViewModel?>;
```
## 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/)

View File

@@ -0,0 +1,23 @@
using MediatR;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Command to change user password.
/// VI: Command để đổi mật khẩu user.
/// </summary>
/// <param name="UserId">User ID / ID user</param>
/// <param name="CurrentPassword">Current password / Mật khẩu hiện tại</param>
/// <param name="NewPassword">New password / Mật khẩu mới</param>
public record ChangePasswordCommand(
Guid UserId,
string CurrentPassword,
string NewPassword) : IRequest<ChangePasswordCommandResult>;
/// <summary>
/// EN: Result of ChangePasswordCommand.
/// VI: Kết quả của ChangePasswordCommand.
/// </summary>
/// <param name="Success">Whether the operation was successful / Thao tác có thành công không</param>
/// <param name="Message">Result message / Thông điệp kết quả</param>
public record ChangePasswordCommandResult(bool Success, string Message);

View File

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

View File

@@ -0,0 +1,18 @@
using MediatR;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Command to logout user (revoke tokens).
/// VI: Command để logout user (thu hồi tokens).
/// </summary>
/// <param name="UserId">User ID / ID user</param>
public record LogoutCommand(Guid UserId) : IRequest<LogoutCommandResult>;
/// <summary>
/// EN: Result of LogoutCommand.
/// VI: Kết quả của LogoutCommand.
/// </summary>
/// <param name="Success">Whether the operation was successful / Thao tác có thành công không</param>
/// <param name="Message">Result message / Thông điệp kết quả</param>
public record LogoutCommandResult(bool Success, string Message);

View File

@@ -0,0 +1,64 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using OpenIddict.Abstractions;
using IamService.Domain.AggregatesModel.UserAggregate;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Handler for LogoutCommand - revokes all tokens for the user.
/// VI: Handler cho LogoutCommand - thu hồi tất cả tokens của user.
/// </summary>
public class LogoutCommandHandler : IRequestHandler<LogoutCommand, LogoutCommandResult>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly IOpenIddictTokenManager _tokenManager;
private readonly ILogger<LogoutCommandHandler> _logger;
public LogoutCommandHandler(
UserManager<ApplicationUser> userManager,
IOpenIddictTokenManager tokenManager,
ILogger<LogoutCommandHandler> logger)
{
_userManager = userManager;
_tokenManager = tokenManager;
_logger = logger;
}
public async Task<LogoutCommandResult> 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.");
}
}
}

View File

@@ -0,0 +1,18 @@
using MediatR;
namespace IamService.API.Application.Commands.Users;
/// <summary>
/// EN: Command to delete a user.
/// VI: Command để xóa user.
/// </summary>
/// <param name="UserId">User ID to delete / ID user cần xóa</param>
public record DeleteUserCommand(Guid UserId) : IRequest<DeleteUserCommandResult>;
/// <summary>
/// EN: Result of DeleteUserCommand.
/// VI: Kết quả của DeleteUserCommand.
/// </summary>
/// <param name="Success">Whether deletion was successful / Xóa có thành công không</param>
/// <param name="Message">Result message / Thông điệp kết quả</param>
public record DeleteUserCommandResult(bool Success, string Message);

View File

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

View File

@@ -0,0 +1,26 @@
using MediatR;
namespace IamService.API.Application.Commands.Users;
/// <summary>
/// EN: Command to update user information.
/// VI: Command để cập nhật thông tin user.
/// </summary>
/// <param name="UserId">User ID to update / ID user cần cập nhật</param>
/// <param name="FirstName">New first name / Tên mới</param>
/// <param name="LastName">New last name / Họ mới</param>
public record UpdateUserCommand(
Guid UserId,
string? FirstName,
string? LastName) : IRequest<UpdateUserCommandResult>;
/// <summary>
/// EN: Result of UpdateUserCommand.
/// VI: Kết quả của UpdateUserCommand.
/// </summary>
public record UpdateUserCommandResult(
Guid UserId,
string Email,
string FirstName,
string LastName,
string FullName);

View File

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

View File

@@ -125,3 +125,51 @@ public class PaginationInfo
/// <example>10</example>
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
}
/// <summary>
/// EN: OAuth2 Token Response (RFC 6749).
/// VI: OAuth2 Token Response (RFC 6749).
/// </summary>
public class TokenResponse
{
/// <summary>
/// EN: The access token issued by the authorization server.
/// VI: Access token được cấp bởi authorization server.
/// </summary>
/// <example>eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...</example>
public string AccessToken { get; set; } = string.Empty;
/// <summary>
/// EN: The type of the token issued (always "Bearer").
/// VI: Loại token được cấp (luôn là "Bearer").
/// </summary>
/// <example>Bearer</example>
public string TokenType { get; set; } = "Bearer";
/// <summary>
/// EN: The lifetime in seconds of the access token.
/// VI: Thời gian sống (giây) của access token.
/// </summary>
/// <example>3600</example>
public int ExpiresIn { get; set; }
/// <summary>
/// EN: The refresh token (if requested).
/// VI: Refresh token (nếu được yêu cầu).
/// </summary>
/// <example>CfDJ8NrU3...</example>
public string? RefreshToken { get; set; }
/// <summary>
/// EN: The scope of the access token.
/// VI: Scope của access token.
/// </summary>
/// <example>openid profile email roles api</example>
public string? Scope { get; set; }
/// <summary>
/// EN: The ID token (if openid scope requested).
/// VI: ID token (nếu openid scope được yêu cầu).
/// </summary>
public string? IdToken { get; set; }
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using IamService.Domain.AggregatesModel.UserAggregate;
namespace IamService.API.Application.Queries.Users;
/// <summary>
/// EN: Query to get user by ID.
/// VI: Query để lấy user theo ID.
/// </summary>
/// <param name="UserId">User ID / ID của user</param>
public record GetUserByIdQuery(Guid UserId) : IRequest<GetUserByIdQueryResult?>;
/// <summary>
/// EN: Query result containing user data.
/// VI: Kết quả query chứa dữ liệu user.
/// </summary>
public record GetUserByIdQueryResult(
Guid Id,
string Email,
string FirstName,
string LastName,
string FullName,
UserStatus Status,
DateTime CreatedAt,
DateTime? LastLoginAt);

View File

@@ -0,0 +1,46 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using IamService.Domain.AggregatesModel.UserAggregate;
namespace IamService.API.Application.Queries.Users;
/// <summary>
/// EN: Handler for GetUserByIdQuery.
/// VI: Handler cho GetUserByIdQuery.
/// </summary>
public class GetUserByIdQueryHandler : IRequestHandler<GetUserByIdQuery, GetUserByIdQueryResult?>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<GetUserByIdQueryHandler> _logger;
public GetUserByIdQueryHandler(
UserManager<ApplicationUser> userManager,
ILogger<GetUserByIdQueryHandler> logger)
{
_userManager = userManager;
_logger = logger;
}
public async Task<GetUserByIdQueryResult?> 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);
}
}

View File

@@ -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
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// **Password Grant (Login):**
/// ```
/// POST /connect/token
/// Content-Type: application/x-www-form-urlencoded
///
/// grant_type=password&amp;username=user@example.com&amp;password=YourPassword&amp;scope=openid profile email roles api
/// ```
///
/// **Refresh Token Grant:**
/// ```
/// POST /connect/token
/// Content-Type: application/x-www-form-urlencoded
///
/// grant_type=refresh_token&amp;refresh_token=YOUR_REFRESH_TOKEN
/// ```
///
/// **Client Credentials Grant:**
/// ```
/// POST /connect/token
/// Content-Type: application/x-www-form-urlencoded
///
/// grant_type=client_credentials&amp;client_id=YOUR_CLIENT_ID&amp;client_secret=YOUR_CLIENT_SECRET&amp;scope=api
/// ```
/// </remarks>
/// <returns>OAuth2 token response with access_token, refresh_token, expires_in</returns>
[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<IActionResult> Exchange()
{
var request = HttpContext.GetOpenIddictServerRequest()
@@ -244,6 +284,76 @@ public class AuthController : ControllerBase
SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme));
}
/// <summary>
/// EN: Change user password.
/// VI: Đổi mật khẩu user.
/// </summary>
/// <param name="request">Change password request data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of password change operation</returns>
[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<IActionResult> 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 });
}
/// <summary>
/// EN: Logout user and revoke tokens.
/// VI: Logout user và thu hồi tokens.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of logout operation</returns>
[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<IActionResult> 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<string> GetDestinations(Claim claim)
{
switch (claim.Type)
@@ -266,3 +376,62 @@ public class AuthController : ControllerBase
}
}
}
/// <summary>
/// EN: Request body for changing password.
/// VI: Request body để đổi mật khẩu.
/// </summary>
public class ChangePasswordRequest
{
/// <summary>
/// EN: Current password.
/// VI: Mật khẩu hiện tại.
/// </summary>
/// <example>OldPassword123!</example>
public string CurrentPassword { get; set; } = string.Empty;
/// <summary>
/// EN: New password.
/// VI: Mật khẩu mới.
/// </summary>
/// <example>NewPassword456!</example>
public string NewPassword { get; set; } = string.Empty;
}
/// <summary>
/// EN: Response for change password operation.
/// VI: Response cho thao tác đổi mật khẩu.
/// </summary>
public class ChangePasswordResponse
{
/// <summary>
/// EN: Whether the operation was successful.
/// VI: Thao tác có thành công không.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// EN: Result message.
/// VI: Thông điệp kết quả.
/// </summary>
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// EN: Response for logout operation.
/// VI: Response cho thao tác logout.
/// </summary>
public class LogoutResponse
{
/// <summary>
/// EN: Whether the operation was successful.
/// VI: Thao tác có thành công không.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// EN: Result message.
/// VI: Thông điệp kết quả.
/// </summary>
public string Message { get; set; } = string.Empty;
}

View File

@@ -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
});
}
/// <summary>
/// EN: Get user by ID.
/// VI: Lấy user theo ID.
/// </summary>
/// <param name="id">User ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>User information</returns>
[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<UserDto>))]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[SwaggerResponse(StatusCodes.Status404NotFound, "User not found")]
[ProducesResponseType(typeof(ApiResponse<UserDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<UserDto>.Fail("USER_NOT_FOUND", $"User with ID {id} not found."));
}
return Ok(ApiResponse<UserDto>.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
}));
}
/// <summary>
/// EN: Update user information.
/// VI: Cập nhật thông tin user.
/// </summary>
/// <param name="id">User ID to update</param>
/// <param name="request">Update data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Updated user information</returns>
[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<UserDto>))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request data")]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[SwaggerResponse(StatusCodes.Status404NotFound, "User not found")]
[ProducesResponseType(typeof(ApiResponse<UserDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<UserDto>.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<UserDto>.Fail("USER_NOT_FOUND", ex.Message));
}
}
/// <summary>
/// EN: Delete (deactivate) a user.
/// VI: Xóa (vô hiệu hóa) user.
/// </summary>
/// <param name="id">User ID to delete</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Deletion result</returns>
[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<DeleteUserResult>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<DeleteUserResult>.Ok(new DeleteUserResult
{
Success = result.Success,
Message = result.Message
}));
}
catch (Exception ex) when (ex.Message.Contains("not found"))
{
return NotFound(ApiResponse<DeleteUserResult>.Fail("USER_NOT_FOUND", ex.Message));
}
}
/// <summary>
/// EN: Get current user info.
/// VI: Lấy thông tin user hiện tại.
@@ -110,3 +240,42 @@ public class UsersController : ControllerBase
}
}
/// <summary>
/// EN: Request body for updating user.
/// VI: Request body để cập nhật user.
/// </summary>
public class UpdateUserRequest
{
/// <summary>
/// EN: New first name.
/// VI: Tên mới.
/// </summary>
/// <example>John</example>
public string? FirstName { get; set; }
/// <summary>
/// EN: New last name.
/// VI: Họ mới.
/// </summary>
/// <example>Doe</example>
public string? LastName { get; set; }
}
/// <summary>
/// EN: Result of delete user operation.
/// VI: Kết quả của thao tác xóa user.
/// </summary>
public class DeleteUserResult
{
/// <summary>
/// EN: Whether the operation was successful.
/// VI: Thao tác có thành công không.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// EN: Result message.
/// VI: Thông điệp kết quả.
/// </summary>
public string Message { get; set; } = string.Empty;
}