From 07f96a8eb2529b4b8eb50e7d6a1d2d51c014bfc3 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Mon, 12 Jan 2026 13:36:53 +0700 Subject: [PATCH] feat(docs): Enhance Vietnamese documentation with new sections and updates - Added new sections on API Design, Caching Patterns, and Testing Patterns to the Vietnamese documentation. - Updated sidebar configurations for improved navigation and accessibility. - Removed outdated onboarding guides to streamline content and focus on relevant resources. --- services/iam-service-net/.env.example | 40 +++ services/iam-service-net/.gitignore | 75 +++++ .../iam-service-net/Directory.Build.props | 22 ++ services/iam-service-net/Dockerfile | 66 +++++ services/iam-service-net/IamService.slnx | 11 + services/iam-service-net/README.md | 166 +++++++++++ services/iam-service-net/docker-compose.yml | 72 +++++ .../iam-service-net/docs/en/ARCHITECTURE.md | 271 ++++++++++++++++++ services/iam-service-net/docs/en/README.md | 265 +++++++++++++++++ .../iam-service-net/docs/vi/ARCHITECTURE.md | 271 ++++++++++++++++++ services/iam-service-net/docs/vi/README.md | 265 +++++++++++++++++ services/iam-service-net/global.json | 7 + .../Application/Behaviors/LoggingBehavior.cs | 58 ++++ .../Behaviors/TransactionBehavior.cs | 84 ++++++ .../Behaviors/ValidatorBehavior.cs | 63 ++++ .../Commands/Auth/RegisterUserCommand.cs | 24 ++ .../Auth/RegisterUserCommandHandler.cs | 71 +++++ .../Queries/Users/GetUsersQuery.cs | 38 +++ .../Queries/Users/GetUsersQueryHandler.cs | 53 ++++ .../RegisterUserCommandValidator.cs | 33 +++ .../Controllers/AuthController.cs | 253 ++++++++++++++++ .../Controllers/UsersController.cs | 84 ++++++ .../src/IamService.API/IamService.API.csproj | 53 ++++ .../src/IamService.API/Program.cs | 180 ++++++++++++ .../Properties/launchSettings.json | 15 + .../appsettings.Development.json | 19 ++ .../src/IamService.API/appsettings.json | 46 +++ .../RoleAggregate/ApplicationRole.cs | 100 +++++++ .../RoleAggregate/IRoleRepository.cs | 46 +++ .../UserAggregate/ApplicationUser.cs | 178 ++++++++++++ .../UserAggregate/IUserRepository.cs | 43 +++ .../UserAggregate/UserStatus.cs | 31 ++ .../Events/RoleAssignedDomainEvent.cs | 23 ++ .../Events/UserLoggedInDomainEvent.cs | 21 ++ .../Events/UserRegisteredDomainEvent.cs | 20 ++ .../Exceptions/DomainException.cs | 21 ++ .../Exceptions/InvalidCredentialsException.cs | 18 ++ .../Exceptions/UserLockedException.cs | 18 ++ .../IamService.Domain.csproj | 17 ++ .../src/IamService.Domain/SeedWork/Entity.cs | 102 +++++++ .../IamService.Domain/SeedWork/Enumeration.cs | 95 ++++++ .../SeedWork/IAggregateRoot.cs | 15 + .../SeedWork/IDomainEvent.cs | 12 + .../IamService.Domain/SeedWork/IRepository.cs | 15 + .../IamService.Domain/SeedWork/IUnitOfWork.cs | 30 ++ .../IamService.Domain/SeedWork/ValueObject.cs | 53 ++++ .../DependencyInjection.cs | 143 +++++++++ .../IamService.Infrastructure.csproj | 43 +++ .../IamServiceContext.cs | 178 ++++++++++++ .../Idempotency/ClientRequest.cs | 26 ++ .../Idempotency/IRequestManager.cs | 24 ++ .../Idempotency/RequestManager.cs | 45 +++ .../Repositories/RoleRepository.cs | 79 +++++ .../Repositories/UserRepository.cs | 81 ++++++ .../Controllers/SamplesControllerTests.cs | 80 ++++++ .../CustomWebApplicationFactory.cs | 56 ++++ .../IamService.FunctionalTests.csproj | 38 +++ .../IamService.UnitTests.csproj | 35 +++ 58 files changed, 4291 insertions(+) create mode 100644 services/iam-service-net/.env.example create mode 100644 services/iam-service-net/.gitignore create mode 100644 services/iam-service-net/Directory.Build.props create mode 100644 services/iam-service-net/Dockerfile create mode 100644 services/iam-service-net/IamService.slnx create mode 100644 services/iam-service-net/README.md create mode 100644 services/iam-service-net/docker-compose.yml create mode 100644 services/iam-service-net/docs/en/ARCHITECTURE.md create mode 100644 services/iam-service-net/docs/en/README.md create mode 100644 services/iam-service-net/docs/vi/ARCHITECTURE.md create mode 100644 services/iam-service-net/docs/vi/README.md create mode 100644 services/iam-service-net/global.json create mode 100644 services/iam-service-net/src/IamService.API/Application/Behaviors/LoggingBehavior.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Behaviors/TransactionBehavior.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Behaviors/ValidatorBehavior.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUsersQuery.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUsersQueryHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Validations/RegisterUserCommandValidator.cs create mode 100644 services/iam-service-net/src/IamService.API/Controllers/AuthController.cs create mode 100644 services/iam-service-net/src/IamService.API/Controllers/UsersController.cs create mode 100644 services/iam-service-net/src/IamService.API/IamService.API.csproj create mode 100644 services/iam-service-net/src/IamService.API/Program.cs create mode 100644 services/iam-service-net/src/IamService.API/Properties/launchSettings.json create mode 100644 services/iam-service-net/src/IamService.API/appsettings.Development.json create mode 100644 services/iam-service-net/src/IamService.API/appsettings.json create mode 100644 services/iam-service-net/src/IamService.Domain/AggregatesModel/RoleAggregate/ApplicationRole.cs create mode 100644 services/iam-service-net/src/IamService.Domain/AggregatesModel/RoleAggregate/IRoleRepository.cs create mode 100644 services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ApplicationUser.cs create mode 100644 services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/IUserRepository.cs create mode 100644 services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/UserStatus.cs create mode 100644 services/iam-service-net/src/IamService.Domain/Events/RoleAssignedDomainEvent.cs create mode 100644 services/iam-service-net/src/IamService.Domain/Events/UserLoggedInDomainEvent.cs create mode 100644 services/iam-service-net/src/IamService.Domain/Events/UserRegisteredDomainEvent.cs create mode 100644 services/iam-service-net/src/IamService.Domain/Exceptions/DomainException.cs create mode 100644 services/iam-service-net/src/IamService.Domain/Exceptions/InvalidCredentialsException.cs create mode 100644 services/iam-service-net/src/IamService.Domain/Exceptions/UserLockedException.cs create mode 100644 services/iam-service-net/src/IamService.Domain/IamService.Domain.csproj create mode 100644 services/iam-service-net/src/IamService.Domain/SeedWork/Entity.cs create mode 100644 services/iam-service-net/src/IamService.Domain/SeedWork/Enumeration.cs create mode 100644 services/iam-service-net/src/IamService.Domain/SeedWork/IAggregateRoot.cs create mode 100644 services/iam-service-net/src/IamService.Domain/SeedWork/IDomainEvent.cs create mode 100644 services/iam-service-net/src/IamService.Domain/SeedWork/IRepository.cs create mode 100644 services/iam-service-net/src/IamService.Domain/SeedWork/IUnitOfWork.cs create mode 100644 services/iam-service-net/src/IamService.Domain/SeedWork/ValueObject.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj create mode 100644 services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Idempotency/ClientRequest.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Idempotency/IRequestManager.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Idempotency/RequestManager.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Repositories/RoleRepository.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Repositories/UserRepository.cs create mode 100644 services/iam-service-net/tests/IamService.FunctionalTests/Controllers/SamplesControllerTests.cs create mode 100644 services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs create mode 100644 services/iam-service-net/tests/IamService.FunctionalTests/IamService.FunctionalTests.csproj create mode 100644 services/iam-service-net/tests/IamService.UnitTests/IamService.UnitTests.csproj diff --git a/services/iam-service-net/.env.example b/services/iam-service-net/.env.example new file mode 100644 index 00000000..f9053bc3 --- /dev/null +++ b/services/iam-service-net/.env.example @@ -0,0 +1,40 @@ +# Environment / Môi Trường +ASPNETCORE_ENVIRONMENT=Development + +# Database / Cơ Sở Dữ Liệu +# PostgreSQL connection string (Neon or local) +DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres + +# Redis Cache +REDIS_URL=localhost:6379 +REDIS_PASSWORD= + +# JWT Authentication / Xác Thực JWT +JWT_SECRET=your-secret-key-min-32-characters-long-here +JWT_ISSUER=goodgo-platform +JWT_AUDIENCE=goodgo-services +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 + +# API Configuration / Cấu Hình API +API_PORT=5000 +API_BASE_PATH=/api/v1/myservice + +# Observability / Quan Sát +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=myservice + +# Logging +LOG_LEVEL=Information +SEQ_URL=http://localhost:5341 + +# Feature Flags +FEATURE_SWAGGER_ENABLED=true +FEATURE_DETAILED_ERRORS=true + +# Rate Limiting +RATE_LIMIT_PERMITS_PER_MINUTE=100 +RATE_LIMIT_QUEUE_LIMIT=10 + +# Health Checks +HEALTHCHECK_TIMEOUT_SECONDS=5 diff --git a/services/iam-service-net/.gitignore b/services/iam-service-net/.gitignore new file mode 100644 index 00000000..84b02a53 --- /dev/null +++ b/services/iam-service-net/.gitignore @@ -0,0 +1,75 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.userosscache +*.suo +*.userprefs +*.sln.docstates + +# Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Coverage +TestResults/ +*.coverage +*.coveragexml +coverage*.json +coverage*.xml + +# Publish output +publish/ +out/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Secrets +appsettings.*.json +!appsettings.json +!appsettings.Development.json + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db + +# JetBrains +*.resharper + +# dotnet tools +.config/dotnet-tools.json + +# Migration scripts (only keep structure) +Migrations/ + +# Temp files +*.tmp +*.temp +~$* diff --git a/services/iam-service-net/Directory.Build.props b/services/iam-service-net/Directory.Build.props new file mode 100644 index 00000000..c3b74373 --- /dev/null +++ b/services/iam-service-net/Directory.Build.props @@ -0,0 +1,22 @@ + + + net10.0 + 14.0 + enable + enable + true + true + $(NoWarn);1591;CA2017 + + + + GoodGo Team + GoodGo + © 2026 GoodGo. All rights reserved. + git + + + + + + diff --git a/services/iam-service-net/Dockerfile b/services/iam-service-net/Dockerfile new file mode 100644 index 00000000..60d5c358 --- /dev/null +++ b/services/iam-service-net/Dockerfile @@ -0,0 +1,66 @@ +# Build stage / Giai đoạn build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# EN: Copy project files for layer caching +# VI: Sao chép các file project để tận dụng layer caching +COPY ["src/IamService.API/IamService.API.csproj", "src/IamService.API/"] +COPY ["src/IamService.Domain/IamService.Domain.csproj", "src/IamService.Domain/"] +COPY ["src/IamService.Infrastructure/IamService.Infrastructure.csproj", "src/IamService.Infrastructure/"] +COPY ["Directory.Build.props", "./"] + +# EN: Restore dependencies +# VI: Khôi phục dependencies +RUN dotnet restore "src/IamService.API/IamService.API.csproj" + +# EN: Copy all source code +# VI: Sao chép toàn bộ source code +COPY src/ ./src/ + +# EN: Build the application +# VI: Build ứng dụng +WORKDIR "/src/src/IamService.API" +RUN dotnet build "IamService.API.csproj" -c Release -o /app/build --no-restore + +# Publish stage / Giai đoạn publish +FROM build AS publish +RUN dotnet publish "IamService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore + +# Runtime stage / Giai đoạn runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# EN: Create non-root user for security +# VI: Tạo user non-root cho bảo mật +RUN groupadd -g 1001 dotnetuser && \ + useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser + +# EN: Copy published application +# VI: Sao chép ứng dụng đã publish +COPY --from=publish /app/publish . + +# EN: Change ownership to non-root user +# VI: Thay đổi quyền sở hữu sang user non-root +RUN chown -R dotnetuser:dotnetuser /app + +# EN: Switch to non-root user +# VI: Chuyển sang user non-root +USER dotnetuser + +# EN: Expose port +# VI: Mở cổng +EXPOSE 8080 + +# EN: Set environment variables +# VI: Thiết lập biến môi trường +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +# EN: Health check +# VI: Kiểm tra health +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health/live || exit 1 + +# EN: Start the application +# VI: Khởi động ứng dụng +ENTRYPOINT ["dotnet", "IamService.API.dll"] diff --git a/services/iam-service-net/IamService.slnx b/services/iam-service-net/IamService.slnx new file mode 100644 index 00000000..a628d91d --- /dev/null +++ b/services/iam-service-net/IamService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/iam-service-net/README.md b/services/iam-service-net/README.md new file mode 100644 index 00000000..f35cf708 --- /dev/null +++ b/services/iam-service-net/README.md @@ -0,0 +1,166 @@ +# IAM Service .NET + +> Identity and Access Management Service built with .NET 10, ASP.NET Core Identity, and OpenIddict. + +## Overview + +IAM Service provides OAuth2/OpenID Connect authentication and authorization capabilities: + +- **User Management**: Registration, profile management, account locking +- **Role-Based Access Control (RBAC)**: Role assignment, permission management +- **OAuth2 Token Endpoints**: Password, Refresh Token, Client Credentials grants +- **JWT Tokens**: Access tokens (15 min), Refresh tokens (7 days) + +## Tech Stack + +| Technology | Purpose | +|------------|---------| +| .NET 10 | Runtime | +| ASP.NET Core Identity | User/Role management | +| OpenIddict | OAuth2/OIDC server | +| EF Core + PostgreSQL | Data persistence | +| MediatR | CQRS pattern | +| FluentValidation | Request validation | +| Serilog | Structured logging | + +## Quick Start + +### 1. Prerequisites + +- .NET SDK 10.0.101+ +- Docker (for PostgreSQL) + +### 2. Setup Environment + +```bash +cp .env.example .env +# Edit DATABASE_URL in .env +``` + +### 3. Run with Docker Compose + +```bash +docker-compose up -d +``` + +Service available at: `http://localhost:5001` + +### 4. Run Locally + +```bash +dotnet restore +dotnet build +dotnet run --project src/IamService.API +``` + +## API Endpoints + +### Authentication + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/auth/register` | Register new user | +| POST | `/connect/token` | OAuth2 token endpoint | + +### Token Request (Password Grant) + +```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!" +``` + +### Users (Protected) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/users` | List users (paginated) | +| GET | `/api/v1/users/me` | Get current user | + +### Health Checks + +| Endpoint | Purpose | +|----------|---------| +| `/health` | Full health status | +| `/health/live` | Liveness probe | +| `/health/ready` | Readiness probe | + +## Project Structure + +``` +iam-service-net/ +├── src/ +│ ├── IamService.API/ # Controllers, CQRS +│ │ ├── Controllers/ # AuthController, UsersController +│ │ └── Application/ # Commands, Queries, Validations +│ ├── IamService.Domain/ # Domain entities +│ │ ├── AggregatesModel/ # UserAggregate, RoleAggregate +│ │ ├── Events/ # Domain events +│ │ └── Exceptions/ # Domain exceptions +│ └── IamService.Infrastructure/ # Data access +│ ├── IamServiceContext.cs # DbContext with Identity +│ └── Repositories/ # Repository implementations +├── tests/ +│ ├── IamService.UnitTests/ +│ └── IamService.FunctionalTests/ +├── Dockerfile +└── docker-compose.yml +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Environment | Development | +| `DATABASE_URL` | PostgreSQL connection | - | +| `REDIS_URL` | Redis connection | - | + +### Password Policy + +- Minimum 8 characters +- Requires uppercase, lowercase, digit, special character + +### Token Lifetimes + +| Token | Lifetime | +|-------|----------| +| Access Token | 15 minutes | +| Refresh Token | 7 days | + +## Development + +```bash +# Restore dependencies +dotnet restore + +# Build +dotnet build + +# Run tests +dotnet test + +# Run API +dotnet run --project src/IamService.API +``` + +## Docker + +```bash +# Build image +docker build -t iam-service:latest . + +# Run container +docker run -p 5001:8080 --env-file .env iam-service:latest +``` + +## Resources + +- [OpenIddict Documentation](https://documentation.openiddict.com/) +- [ASP.NET Core Identity](https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity) +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) + +## License + +Proprietary - GoodGo Platform diff --git a/services/iam-service-net/docker-compose.yml b/services/iam-service-net/docker-compose.yml new file mode 100644 index 00000000..4663af07 --- /dev/null +++ b/services/iam-service-net/docker-compose.yml @@ -0,0 +1,72 @@ +version: '3.8' + +# EN: Docker Compose for local development +# VI: Docker Compose cho phát triển local + +services: + iamservice-api: + build: + context: . + dockerfile: Dockerfile + container_name: iamservice-api + ports: + - "5001:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DATABASE_URL=Host=postgres;Port=5432;Database=iamservice_db;Username=postgres;Password=postgres + - REDIS_URL=redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - iamservice-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + postgres: + image: postgres:16-alpine + container_name: iamservice-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: iamservice_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - iamservice-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: iamservice-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - iamservice-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: + +networks: + iamservice-network: + driver: bridge diff --git a/services/iam-service-net/docs/en/ARCHITECTURE.md b/services/iam-service-net/docs/en/ARCHITECTURE.md new file mode 100644 index 00000000..7aecf3ad --- /dev/null +++ b/services/iam-service-net/docs/en/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Architecture Documentation + +> Detailed architecture documentation for the .NET 10 Microservice Template. + +## Architecture Overview + +```mermaid +graph TB + subgraph "API Layer" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Domain Layer" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Infrastructure Layer" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Layer Responsibilities + +### 1. Domain Layer (IamService.Domain) + +The heart of the application containing pure business logic. This layer: +- Has **ZERO** external dependencies (except MediatR.Contracts for events) +- Contains only POCO classes +- Implements DDD tactical patterns + +#### Components + +| Component | Purpose | +|-----------|---------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots with their entities and value objects | +| **Events** | Domain events for cross-aggregate communication | +| **Exceptions** | Domain-specific exceptions for business rule violations | + +### 2. Infrastructure Layer (IamService.Infrastructure) + +Technical implementations and external concerns: +- Database access (EF Core) +- Repository implementations +- External service integrations + +### 3. API Layer (IamService.API) + +Application entry point and CQRS implementation: +- Controllers for HTTP handling +- Commands for write operations +- Queries for read operations +- MediatR behaviors for cross-cutting concerns + +## CQRS Flow + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Raises| DE[Domain Event] + DE -->|Dispatched by| CTX[DbContext] + CTX -->|Publishes to| M[MediatR] + M -->|Handled by| H1[Handler 1] + M -->|Handled by| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Database Schema + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## MediatR Pipeline + +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing with Transaction + FluentValidation +``` + +### Behavior Order + +1. **LoggingBehavior** - Logs request handling with timing +2. **ValidatorBehavior** - Validates request using FluentValidation +3. **TransactionBehavior** - Wraps command handlers in database transactions + +## Error Handling + +### Exception Hierarchy + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +All errors are returned in Problem Details format: + +```json +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Validation Error", + "status": 400, + "detail": "One or more validation errors occurred.", + "errors": { + "Name": ["Name is required"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Deployment Architecture + +### Docker Compose (Local) + +```yaml +services: + iamservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine +``` + +### Kubernetes (Production) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: iamservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: iamservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Security Considerations + +1. **Authentication**: JWT Bearer token (configure in production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation on all requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Environment variables, never in code + +## Performance Optimization + +1. **Connection Pooling**: EF Core with Npgsql connection resilience +2. **Async/Await**: All I/O operations are async +3. **Response Caching**: Add caching headers for queries +4. **Database Indexes**: Configure in EntityConfigurations + +## References + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [.NET Microservices Architecture Guide](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/iam-service-net/docs/en/README.md b/services/iam-service-net/docs/en/README.md new file mode 100644 index 00000000..8240ad39 --- /dev/null +++ b/services/iam-service-net/docs/en/README.md @@ -0,0 +1,265 @@ +# .NET 10 Microservice Template + +> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns. + +## Overview + +This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with: + +- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events +- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR +- **Clean Architecture** - Domain, Infrastructure, API layered separation +- **EF Core 10** - PostgreSQL with connection resilience +- **FluentValidation** - Request validation +- **API Versioning** - URL segment versioning +- **Health Checks** - Kubernetes-ready probes +- **Structured Logging** - Serilog with console and Seq + +## Prerequisites + +| Requirement | Version | +|-------------|---------| +| .NET SDK | 10.0.101+ | +| Docker | 24.0+ | +| PostgreSQL | 15+ (or use Docker) | + +```bash +# Check .NET version +dotnet --version +# Should output: 10.0.xxx +``` + +## Quick Start + +### 1. Create New Service + +```bash +# Copy template to new service +cp -r services/_template_dot_net services/your-service-name + +# Navigate to service directory +cd services/your-service-name + +# Rename all occurrences of "IamService" to "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. Configure Environment + +```bash +# Copy environment template +cp .env.example .env + +# Edit with your configuration +nano .env +``` + +### 3. Run with Docker + +```bash +# Start all services (API + PostgreSQL + Redis) +docker-compose up -d + +# View logs +docker-compose logs -f iamservice-api +``` + +### 4. Run Locally + +```bash +# Restore dependencies +dotnet restore + +# Build all projects +dotnet build + +# Run the API +dotnet run --project src/IamService.API +``` + +## Project Structure + +``` +_template_dot_net/ +├── src/ +│ ├── IamService.API/ # Presentation Layer (Controllers, CQRS) +│ │ ├── Controllers/ # API endpoints +│ │ ├── Application/ # CQRS Implementation +│ │ │ ├── Commands/ # Write operations (MediatR) +│ │ │ ├── Queries/ # Read operations +│ │ │ ├── Behaviors/ # MediatR pipeline behaviors +│ │ │ └── Validations/ # FluentValidation validators +│ │ ├── Middleware/ # Custom middleware +│ │ └── Program.cs # Application entry point +│ │ +│ ├── IamService.Domain/ # Domain Layer (Pure business logic) +│ │ ├── AggregatesModel/ # Aggregate roots and entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── IamService.Infrastructure/ # Infrastructure Layer (Data access) +│ ├── EntityConfigurations/ # EF Core Fluent API configurations +│ ├── Repositories/ # Repository implementations +│ ├── Idempotency/ # Request idempotency handling +│ └── IamServiceContext.cs # DbContext with Unit of Work +│ +├── tests/ +│ ├── IamService.UnitTests/ # Unit tests (Domain, Application) +│ └── IamService.FunctionalTests/ # Integration tests (API endpoints) +│ +├── Dockerfile # Multi-stage Docker build +├── docker-compose.yml # Local development setup +├── global.json # .NET SDK version pinning +└── Directory.Build.props # Common MSBuild properties +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/samples` | Get all samples | +| `GET` | `/api/v1/samples/{id}` | Get sample by ID | +| `POST` | `/api/v1/samples` | Create new sample | +| `PUT` | `/api/v1/samples/{id}` | Update sample | +| `DELETE` | `/api/v1/samples/{id}` | Delete sample | +| `PATCH` | `/api/v1/samples/{id}/status` | Change status | + +### Health Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `/health` | Full health status | +| `/health/live` | Liveness probe | +| `/health/ready` | Readiness probe | + +## CQRS Pattern + +### Commands (Write Operations) + +```csharp +// Define command +public record CreateSampleCommand(string Name, string? Description) + : IRequest; + +// Handle 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 (Read Operations) + +```csharp +// Define 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) { + // Business logic validation + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + // Domain event + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + public void Activate() { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Only draft samples can be activated"); + // State transition + } +} +``` + +## Testing + +```bash +# Run all tests +dotnet test + +# Run with coverage +dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura + +# Run specific test project +dotnet test tests/IamService.UnitTests +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` | +| `DATABASE_URL` | PostgreSQL connection string | - | +| `REDIS_URL` | Redis connection string | - | +| `JWT_SECRET` | JWT signing secret (min 32 chars) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=iamservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Deployment + +### Docker Build + +```bash +# Build Docker image +docker build -t iamservice:latest . + +# Run container +docker run -p 5000:8080 --env-file .env iamservice:latest +``` + +### Kubernetes + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests. + +## What's New in .NET 10 + +- **C# 14** language features +- Improved **Native AOT** support +- Better **async/await** performance +- Enhanced **JSON serialization** +- Performance improvements across the board +- 3-year **LTS** support (until November 2028) + +## Resources + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Reference architecture +- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10) +- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/) +- [MediatR](https://github.com/jbogard/MediatR) - CQRS library +- [FluentValidation](https://docs.fluentvalidation.net/) - Validation library + +## License + +Proprietary - GoodGo Platform diff --git a/services/iam-service-net/docs/vi/ARCHITECTURE.md b/services/iam-service-net/docs/vi/ARCHITECTURE.md new file mode 100644 index 00000000..3fb25178 --- /dev/null +++ b/services/iam-service-net/docs/vi/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Tài Liệu Kiến Trúc + +> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10. + +## Tổng Quan Kiến Trúc + +```mermaid +graph TB + subgraph "Lớp API" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Lớp Domain" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Lớp Infrastructure" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Trách Nhiệm Các Lớp + +### 1. Lớp Domain (IamService.Domain) + +Trái tim của ứng dụng chứa business logic thuần túy. Lớp này: +- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events) +- Chỉ chứa các class POCO +- Triển khai các tactical patterns của DDD + +#### Thành Phần + +| Thành phần | Mục Đích | +|------------|----------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots với entities và value objects | +| **Events** | Domain events cho giao tiếp cross-aggregate | +| **Exceptions** | Domain exceptions cho vi phạm business rules | + +### 2. Lớp Infrastructure (IamService.Infrastructure) + +Triển khai kỹ thuật và các mối quan tâm bên ngoài: +- Truy cập database (EF Core) +- Triển khai repositories +- Tích hợp external services + +### 3. Lớp API (IamService.API) + +Điểm vào ứng dụng và triển khai CQRS: +- Controllers để xử lý HTTP +- Commands cho các thao tác ghi +- Queries cho các thao tác đọc +- MediatR behaviors cho cross-cutting concerns + +## Luồng CQRS + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + Client->>Controller: HTTP Request + Controller->>MediatR: Send(Command) + MediatR->>LoggingBehavior: Handle + LoggingBehavior->>ValidatorBehavior: Next() + ValidatorBehavior->>TransactionBehavior: Next() + TransactionBehavior->>CommandHandler: Next() + CommandHandler->>Repository: Add/Update/Delete + Repository->>DbContext: SaveEntitiesAsync() + DbContext-->>Repository: Success + Repository-->>CommandHandler: Result + CommandHandler-->>Controller: Response + Controller-->>Client: HTTP Response +``` + +## Domain Events + +```mermaid +graph LR + AR[Aggregate Root] -->|Phát sinh| DE[Domain Event] + DE -->|Dispatch bởi| CTX[DbContext] + CTX -->|Publish tới| M[MediatR] + M -->|Xử lý bởi| H1[Handler 1] + M -->|Xử lý bởi| H2[Handler 2] + + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DE fill:#f39c12,stroke:#d68910,color:#fff + style M fill:#9b59b6,stroke:#7d3c98,color:#fff +``` + +## Schema Database + +### Sample Aggregate + +```mermaid +erDiagram + samples { + uuid id PK + varchar(200) name + varchar(1000) description + int status_id FK + timestamp created_at + timestamp updated_at + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## Pipeline MediatR + +``` +Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response + │ │ │ + ▼ ▼ ▼ + Log start/end Validate Begin/Commit + + timing với Transaction + FluentValidation +``` + +### Thứ Tự Behaviors + +1. **LoggingBehavior** - Ghi log xử lý request với timing +2. **ValidatorBehavior** - Validate request sử dụng FluentValidation +3. **TransactionBehavior** - Bao bọc command handlers trong database transactions + +## Xử Lý Lỗi + +### Phân Cấp Exceptions + +``` +Exception +└── DomainException + └── SampleDomainException +``` + +### Problem Details (RFC 7807) + +Tất cả lỗi được trả về theo định dạng Problem Details: + +```json +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Lỗi Validation", + "status": 400, + "detail": "Một hoặc nhiều lỗi validation đã xảy ra.", + "errors": { + "Name": ["Tên là bắt buộc"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Kiến Trúc Deployment + +### Docker Compose (Local) + +```yaml +services: + iamservice-api: + build: . + ports: ["5000:8080"] + depends_on: + - postgres + - redis + + postgres: + image: postgres:16-alpine + + redis: + image: redis:7-alpine +``` + +### Kubernetes (Production) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: iamservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: iamservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Cân Nhắc Bảo Mật + +1. **Authentication**: JWT Bearer token (cấu hình trong production) +2. **Authorization**: Role-based access control +3. **Input Validation**: FluentValidation trên tất cả requests +4. **SQL Injection**: EF Core parameterized queries +5. **Secrets**: Biến môi trường, không bao giờ trong code + +## Tối Ưu Hiệu Năng + +1. **Connection Pooling**: EF Core với Npgsql connection resilience +2. **Async/Await**: Tất cả I/O operations đều async +3. **Response Caching**: Thêm caching headers cho queries +4. **Database Indexes**: Cấu hình trong EntityConfigurations + +## Tài Liệu Tham Khảo + +- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) +- [Hướng dẫn Kiến trúc .NET Microservices](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) +- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) diff --git a/services/iam-service-net/docs/vi/README.md b/services/iam-service-net/docs/vi/README.md new file mode 100644 index 00000000..06697a71 --- /dev/null +++ b/services/iam-service-net/docs/vi/README.md @@ -0,0 +1,265 @@ +# Template Microservice .NET 10 + +> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture. + +## 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: + +- **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 + +## Yêu Cầu + +| Yêu cầu | Phiên bả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 +``` + +## Bắt Đầu Nhanh + +### 1. Tạo Service Mới + +```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) +docker-compose up -d + +# Xem logs +docker-compose logs -f iamservice-api +``` + +### 4. Chạy Local + +```bash +# Khôi phục dependencies +dotnet restore + +# Build tất cả projects +dotnet build + +# Chạy API +dotnet run --project src/IamService.API +``` + +## Cấu Trúc Dự Án + +``` +_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 +``` + +## Các Endpoint API + +| 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 | + +### Health Endpoints + +| Endpoint | Mục Đích | +|----------|----------| +| `/health` | Trạng thái health đầy đủ | +| `/health/live` | Kiểm tra sống | +| `/health/ready` | Kiểm tra sẵn sàng | + +## Pattern CQRS + +### 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ử + +```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 +``` + +## 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ự) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=iamservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Triển Khai + +### Docker Build + +```bash +# Build Docker image +docker build -t iamservice:latest . + +# Chạy container +docker run -p 5000:8080 --env-file .env iamservice: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 diff --git a/services/iam-service-net/global.json b/services/iam-service-net/global.json new file mode 100644 index 00000000..f78eeaf4 --- /dev/null +++ b/services/iam-service-net/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.101", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/services/iam-service-net/src/IamService.API/Application/Behaviors/LoggingBehavior.cs b/services/iam-service-net/src/IamService.API/Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 00000000..fdf05dea --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using MediatR; + +namespace IamService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for logging request handling. +/// VI: MediatR behavior để logging việc xử lý request. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class LoggingBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation( + "Handling {RequestName} / Đang xử lý {RequestName}", + requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(); + + stopwatch.Stop(); + + _logger.LogInformation( + "Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogError(ex, + "Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + throw; + } + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Behaviors/TransactionBehavior.cs b/services/iam-service-net/src/IamService.API/Application/Behaviors/TransactionBehavior.cs new file mode 100644 index 00000000..c041d0e0 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Behaviors/TransactionBehavior.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using IamService.Infrastructure; + +namespace IamService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for handling database transactions. +/// VI: MediatR behavior để xử lý database transactions. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class TransactionBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IamServiceContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + IamServiceContext dbContext, + ILogger> logger) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + // EN: Skip transaction for queries (read operations) + // VI: Bỏ qua transaction cho queries (các thao tác đọc) + if (requestName.EndsWith("Query")) + { + return await next(); + } + + // EN: Skip if already in a transaction + // VI: Bỏ qua nếu đã trong transaction + if (_dbContext.HasActiveTransaction) + { + return await next(); + } + + var strategy = _dbContext.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _dbContext.BeginTransactionAsync(); + + _logger.LogInformation( + "Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + try + { + var response = await next(); + + if (transaction != null) + { + await _dbContext.CommitTransactionAsync(transaction); + + _logger.LogInformation( + "Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}", + transaction.TransactionId, requestName); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + await _dbContext.RollbackTransactionAsync(); + throw; + } + }); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Behaviors/ValidatorBehavior.cs b/services/iam-service-net/src/IamService.API/Application/Behaviors/ValidatorBehavior.cs new file mode 100644 index 00000000..968a454a --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using MediatR; + +namespace IamService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for FluentValidation integration. +/// VI: MediatR behavior để tích hợp FluentValidation. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class ValidatorBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + private readonly ILogger> _logger; + + public ValidatorBehavior( + IEnumerable> validators, + ILogger> logger) + { + _validators = validators ?? throw new ArgumentNullException(nameof(validators)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + if (!_validators.Any()) + { + return await next(); + } + + _logger.LogDebug( + "Validating {RequestName} / Đang validate {RequestName}", + requestName); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + _logger.LogWarning( + "Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi", + requestName, failures.Count); + + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommand.cs new file mode 100644 index 00000000..c46aa8d4 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommand.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Command to register a new user. +/// VI: Command để đăng ký user mới. +/// +public record RegisterUserCommand( + string Email, + string Password, + string FirstName, + string LastName +) : IRequest; + +/// +/// EN: Result of user registration. +/// VI: Kết quả đăng ký user. +/// +public record RegisterUserCommandResult( + Guid UserId, + string Email, + string FullName +); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommandHandler.cs new file mode 100644 index 00000000..7caea26e --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/RegisterUserCommandHandler.cs @@ -0,0 +1,71 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.Events; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Handler for RegisterUserCommand. +/// VI: Handler cho RegisterUserCommand. +/// +public class RegisterUserCommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public RegisterUserCommandHandler( + UserManager userManager, + ILogger logger) + { + _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + RegisterUserCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Registering new user with email {Email}", + request.Email); + + // EN: Check if user already exists + // VI: Kiểm tra xem user đã tồn tại chưa + var existingUser = await _userManager.FindByEmailAsync(request.Email); + if (existingUser != null) + { + _logger.LogWarning("User with email {Email} already exists", request.Email); + throw new InvalidOperationException($"User with email {request.Email} already exists"); + } + + // EN: Create new user + // VI: Tạo user mới + var user = new ApplicationUser(request.Email, request.FirstName, request.LastName); + + // EN: Add domain event + // VI: Thêm domain event + user.AddDomainEvent(new UserRegisteredDomainEvent(user)); + + // EN: Create user with password + // VI: Tạo user với password + var result = await _userManager.CreateAsync(user, request.Password); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogWarning("Failed to create user: {Errors}", errors); + throw new InvalidOperationException($"Failed to create user: {errors}"); + } + + _logger.LogInformation( + "Successfully registered user {UserId} with email {Email}", + user.Id, user.Email); + + return new RegisterUserCommandResult( + user.Id, + user.Email!, + user.FullName); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUsersQuery.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUsersQuery.cs new file mode 100644 index 00000000..7b04dce5 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUsersQuery.cs @@ -0,0 +1,38 @@ +using MediatR; + +namespace IamService.API.Application.Queries.Users; + +/// +/// EN: Query to get users with pagination. +/// VI: Query để lấy danh sách users với phân trang. +/// +public record GetUsersQuery( + int PageNumber = 1, + int PageSize = 10 +) : IRequest; + +/// +/// EN: Result of get users query. +/// VI: Kết quả query lấy users. +/// +public record GetUsersQueryResult( + IEnumerable Users, + int TotalCount, + int PageNumber, + int PageSize +); + +/// +/// EN: User view model for queries. +/// VI: User view model cho queries. +/// +public record UserViewModel( + Guid Id, + string Email, + string FirstName, + string LastName, + string FullName, + string Status, + DateTime CreatedAt, + DateTime? LastLoginAt +); diff --git a/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUsersQueryHandler.cs b/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUsersQueryHandler.cs new file mode 100644 index 00000000..f06e5e01 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Queries/Users/GetUsersQueryHandler.cs @@ -0,0 +1,53 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.API.Application.Queries.Users; + +/// +/// EN: Handler for GetUsersQuery. +/// VI: Handler cho GetUsersQuery. +/// +public class GetUsersQueryHandler : IRequestHandler +{ + private readonly IUserRepository _userRepository; + private readonly ILogger _logger; + + public GetUsersQueryHandler( + IUserRepository userRepository, + ILogger logger) + { + _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + GetUsersQuery request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Getting users page {PageNumber} with size {PageSize}", + request.PageNumber, request.PageSize); + + var (users, totalCount) = await _userRepository.GetAllAsync( + request.PageNumber, + request.PageSize, + cancellationToken); + + var userViewModels = users.Select(u => new UserViewModel( + u.Id, + u.Email!, + u.FirstName, + u.LastName, + u.FullName, + u.Status.Name, + u.CreatedAt, + u.LastLoginAt)); + + return new GetUsersQueryResult( + userViewModels, + totalCount, + request.PageNumber, + request.PageSize); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Validations/RegisterUserCommandValidator.cs b/services/iam-service-net/src/IamService.API/Application/Validations/RegisterUserCommandValidator.cs new file mode 100644 index 00000000..3fda3136 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Validations/RegisterUserCommandValidator.cs @@ -0,0 +1,33 @@ +using FluentValidation; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Validator for RegisterUserCommand. +/// VI: Validator cho RegisterUserCommand. +/// +public class RegisterUserCommandValidator : AbstractValidator +{ + public RegisterUserCommandValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Password is required") + .MinimumLength(8).WithMessage("Password must be at least 8 characters") + .Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter") + .Matches("[a-z]").WithMessage("Password must contain at least one lowercase letter") + .Matches("[0-9]").WithMessage("Password must contain at least one digit") + .Matches("[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character"); + + RuleFor(x => x.FirstName) + .NotEmpty().WithMessage("First name is required") + .MaximumLength(100).WithMessage("First name cannot exceed 100 characters"); + + RuleFor(x => x.LastName) + .NotEmpty().WithMessage("Last name is required") + .MaximumLength(100).WithMessage("Last name cannot exceed 100 characters"); + } +} diff --git a/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs b/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs new file mode 100644 index 00000000..a6055ef5 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs @@ -0,0 +1,253 @@ +using System.Security.Claims; +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using IamService.API.Application.Commands.Auth; +using IamService.Domain.AggregatesModel.UserAggregate; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace IamService.API.Controllers; + +/// +/// EN: Authentication controller with OpenIddict OAuth2/OIDC endpoints. +/// VI: Controller xác thực với OpenIddict OAuth2/OIDC endpoints. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/auth")] +public class AuthController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public AuthController( + IMediator mediator, + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _mediator = mediator; + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + /// + /// EN: Register a new user. + /// VI: Đăng ký user mới. + /// + [HttpPost("register")] + [ProducesResponseType(typeof(RegisterUserCommandResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Register( + [FromBody] RegisterUserCommand command, + CancellationToken cancellationToken) + { + var result = await _mediator.Send(command, cancellationToken); + return CreatedAtAction(nameof(Register), new { id = result.UserId }, result); + } + + /// + /// EN: OAuth2 Token endpoint (handled by OpenIddict). + /// VI: OAuth2 Token endpoint (được xử lý bởi OpenIddict). + /// + [HttpPost("~/connect/token")] + [Consumes("application/x-www-form-urlencoded")] + [Produces("application/json")] + public async Task Exchange() + { + var request = HttpContext.GetOpenIddictServerRequest() + ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + + if (request.IsPasswordGrantType()) + { + return await HandlePasswordGrantAsync(request); + } + + if (request.IsRefreshTokenGrantType()) + { + return await HandleRefreshTokenGrantAsync(); + } + + if (request.IsClientCredentialsGrantType()) + { + return await HandleClientCredentialsGrantAsync(request); + } + + _logger.LogWarning("Unsupported grant type: {GrantType}", request.GrantType); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.UnsupportedGrantType, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The specified grant type is not supported." + })); + } + + private async Task HandlePasswordGrantAsync(OpenIddictRequest request) + { + var user = await _userManager.FindByEmailAsync(request.Username!); + if (user == null) + { + _logger.LogWarning("Login failed: user not found for {Email}", request.Username); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid email or password." + })); + } + + // EN: Check password + // VI: Kiểm tra password + var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password!, lockoutOnFailure: true); + + if (result.IsLockedOut) + { + _logger.LogWarning("Login failed: user {UserId} is locked out", user.Id); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Account is locked. Please try again later." + })); + } + + if (!result.Succeeded) + { + _logger.LogWarning("Login failed: invalid password for user {UserId}", user.Id); + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid email or password." + })); + } + + // EN: Record login and create claims + // VI: Ghi nhận login và tạo claims + user.RecordLogin(); + + var identity = new ClaimsIdentity( + authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + nameType: Claims.Name, + roleType: Claims.Role); + + // EN: Add claims + // VI: Thêm claims + identity.SetClaim(Claims.Subject, user.Id.ToString()) + .SetClaim(Claims.Email, user.Email) + .SetClaim(Claims.Name, user.FullName) + .SetClaim("first_name", user.FirstName) + .SetClaim("last_name", user.LastName); + + // EN: Add roles to claims + // VI: Thêm roles vào claims + var roles = await _userManager.GetRolesAsync(user); + identity.SetClaims(Claims.Role, [.. roles]); + + // EN: Set destinations for claims + // VI: Set destinations cho claims + identity.SetDestinations(GetDestinations); + + var principal = new ClaimsPrincipal(identity); + + // EN: Set scopes + // VI: Set scopes + principal.SetScopes(request.GetScopes()); + + _logger.LogInformation("User {UserId} logged in successfully", user.Id); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private async Task HandleRefreshTokenGrantAsync() + { + var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + var userId = result.Principal?.GetClaim(Claims.Subject); + + if (string.IsNullOrEmpty(userId)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The refresh token is no longer valid." + })); + } + + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user no longer exists." + })); + } + + // EN: Recreate principal with updated claims + // VI: Tạo lại principal với claims đã cập nhật + var identity = new ClaimsIdentity(result.Principal!.Claims, + authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + nameType: Claims.Name, + roleType: Claims.Role); + + identity.SetDestinations(GetDestinations); + + return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private Task HandleClientCredentialsGrantAsync(OpenIddictRequest request) + { + var identity = new ClaimsIdentity( + authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + nameType: Claims.Name, + roleType: Claims.Role); + + identity.SetClaim(Claims.Subject, request.ClientId); + + identity.SetDestinations(GetDestinations); + + var principal = new ClaimsPrincipal(identity); + principal.SetScopes(request.GetScopes()); + + return Task.FromResult( + SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)); + } + + private static IEnumerable GetDestinations(Claim claim) + { + switch (claim.Type) + { + case Claims.Name or Claims.Email: + yield return Destinations.AccessToken; + if (claim.Subject?.HasScope(Scopes.Profile) == true) + yield return Destinations.IdentityToken; + yield break; + + case Claims.Role: + yield return Destinations.AccessToken; + if (claim.Subject?.HasScope(Scopes.Roles) == true) + yield return Destinations.IdentityToken; + yield break; + + default: + yield return Destinations.AccessToken; + yield break; + } + } +} diff --git a/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs b/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs new file mode 100644 index 00000000..2f86e52a --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs @@ -0,0 +1,84 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Validation.AspNetCore; +using IamService.API.Application.Queries.Users; + +namespace IamService.API.Controllers; + +/// +/// EN: Users management controller. +/// VI: Controller quản lý users. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/users")] +[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] +public class UsersController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public UsersController( + IMediator mediator, + ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get all users with pagination. + /// VI: Lấy tất cả users với phân trang. + /// + [HttpGet] + [ProducesResponseType(typeof(GetUsersQueryResult), StatusCodes.Status200OK)] + public async Task GetUsers( + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 10, + CancellationToken cancellationToken = default) + { + var query = new GetUsersQuery(pageNumber, pageSize); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new + { + success = true, + data = result.Users, + pagination = new + { + pageNumber = result.PageNumber, + pageSize = result.PageSize, + totalCount = result.TotalCount, + totalPages = (int)Math.Ceiling(result.TotalCount / (double)result.PageSize) + } + }); + } + + /// + /// EN: Get current user info. + /// VI: Lấy thông tin user hiện tại. + /// + [HttpGet("me")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult GetCurrentUser() + { + var userId = User.FindFirst("sub")?.Value; + var email = User.FindFirst("email")?.Value; + var name = User.FindFirst("name")?.Value; + var roles = User.FindAll("role").Select(c => c.Value); + + return Ok(new + { + success = true, + data = new + { + id = userId, + email, + name, + roles + } + }); + } +} diff --git a/services/iam-service-net/src/IamService.API/IamService.API.csproj b/services/iam-service-net/src/IamService.API/IamService.API.csproj new file mode 100644 index 00000000..d122c115 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/IamService.API.csproj @@ -0,0 +1,53 @@ + + + + IamService.API + IamService.API + Web API layer with CQRS pattern + iamservice-api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/iam-service-net/src/IamService.API/Program.cs b/services/iam-service-net/src/IamService.API/Program.cs new file mode 100644 index 00000000..4777d9c4 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Program.cs @@ -0,0 +1,180 @@ +using Asp.Versioning; +using FluentValidation; +using Hellang.Middleware.ProblemDetails; +using IamService.API.Application.Behaviors; +using IamService.Infrastructure; +using Serilog; + +// EN: Configure Serilog early / VI: Cấu hình Serilog sớm +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + Log.Information("Starting IAM Service API / Khởi động IAM Service API"); + + var builder = WebApplication.CreateBuilder(args); + + // EN: Configure Serilog / VI: Cấu hình Serilog + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console()); + + // EN: Add Infrastructure services (Identity, OpenIddict, Repositories) + // VI: Thêm Infrastructure services (Identity, OpenIddict, Repositories) + builder.Services.AddInfrastructure(builder.Configuration); + + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)); + cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); + }); + + // EN: Add FluentValidation / VI: Thêm FluentValidation + builder.Services.AddValidatorsFromAssemblyContaining(); + + // EN: Add API versioning / VI: Thêm API versioning + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + // EN: Add controllers / VI: Thêm controllers + builder.Services.AddControllers(); + + // EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware + builder.Services.AddProblemDetails(options => + { + options.IncludeExceptionDetails = (ctx, ex) => + builder.Environment.IsDevelopment(); + }); + + // EN: Add Swagger / VI: Thêm Swagger + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "IAM Service API", + Version = "v1", + Description = "Identity and Access Management Service - OAuth2/OIDC API" + }); + + // EN: Add OAuth2 security definition / VI: Thêm OAuth2 security definition + options.AddSecurityDefinition("oauth2", new() + { + Type = Microsoft.OpenApi.Models.SecuritySchemeType.OAuth2, + Flows = new() + { + Password = new() + { + TokenUrl = new Uri("/connect/token", UriKind.Relative), + Scopes = new Dictionary + { + ["openid"] = "OpenID", + ["profile"] = "Profile", + ["email"] = "Email", + ["roles"] = "Roles", + ["api"] = "API access" + } + } + } + }); + + options.AddSecurityRequirement(new() + { + { + new() { Reference = new() { Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, Id = "oauth2" } }, + ["api"] + } + }); + }); + + // EN: Add health checks / VI: Thêm health checks + builder.Services.AddHealthChecks() + .AddNpgSql( + builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? "", + name: "postgresql", + tags: ["db", "postgresql"]); + + // EN: Add CORS / VI: Thêm CORS + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline + app.UseSerilogRequestLogging(); + app.UseProblemDetails(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "IAM Service API v1"); + c.RoutePrefix = "swagger"; + c.OAuthClientId("swagger-ui"); + c.OAuthUsePkce(); + }); + } + + app.UseCors(); + app.UseRouting(); + + // EN: Authentication and Authorization / VI: Xác thực và phân quyền + app.UseAuthentication(); + app.UseAuthorization(); + + // EN: Map health check endpoints / VI: Map health check endpoints + app.MapHealthChecks("/health"); + app.MapHealthChecks("/health/live", new() + { + Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy + }); + app.MapHealthChecks("/health/ready"); + + // EN: Map controllers / VI: Map controllers + app.MapControllers(); + + // EN: Run the application / VI: Chạy ứng dụng + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +// EN: Make Program class accessible for integration tests +// VI: Làm cho class Program có thể truy cập cho integration tests +public partial class Program { } diff --git a/services/iam-service-net/src/IamService.API/Properties/launchSettings.json b/services/iam-service-net/src/IamService.API/Properties/launchSettings.json new file mode 100644 index 00000000..6355d40b --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/services/iam-service-net/src/IamService.API/appsettings.Development.json b/services/iam-service-net/src/IamService.API/appsettings.Development.json new file mode 100644 index 00000000..e407ac85 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "System": "Information" + } + } + } +} \ No newline at end of file diff --git a/services/iam-service-net/src/IamService.API/appsettings.json b/services/iam-service-net/src/IamService.API/appsettings.json new file mode 100644 index 00000000..237d7fd4 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/appsettings.json @@ -0,0 +1,46 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=iamservice_db;Username=postgres;Password=postgres" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/RoleAggregate/ApplicationRole.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/RoleAggregate/ApplicationRole.cs new file mode 100644 index 00000000..e9ba654d --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/RoleAggregate/ApplicationRole.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Identity; +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.RoleAggregate; + +/// +/// EN: Application role entity extending ASP.NET Core Identity. +/// VI: Entity role mở rộng từ ASP.NET Core Identity. +/// +public class ApplicationRole : IdentityRole, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private string? _description; + private DateTime _createdAt; + private bool _isSystemRole; + + private readonly List _domainEvents = []; + + /// + /// EN: Role description. + /// VI: Mô tả role. + /// + public string? Description => _description; + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Whether this is a system-defined role. + /// VI: Role có phải là system role không. + /// + public bool IsSystemRole => _isSystemRole; + + /// + /// EN: Domain events. + /// VI: Domain events. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected ApplicationRole() + { + } + + /// + /// EN: Create a new role. + /// VI: Tạo role mới. + /// + public ApplicationRole(string name, string? description = null, bool isSystemRole = false) : this() + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Role name cannot be empty", nameof(name)); + + Id = Guid.NewGuid(); + Name = name; + NormalizedName = name.ToUpperInvariant(); + _description = description; + _isSystemRole = isSystemRole; + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Update role information. + /// VI: Cập nhật thông tin role. + /// + public void Update(string name, string? description) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Role name cannot be empty", nameof(name)); + + Name = name; + NormalizedName = name.ToUpperInvariant(); + _description = description; + } + + /// + /// EN: Add domain event. + /// VI: Thêm domain event. + /// + public void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Add(domainEvent); + } + + /// + /// EN: Clear domain events. + /// VI: Xóa domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/RoleAggregate/IRoleRepository.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/RoleAggregate/IRoleRepository.cs new file mode 100644 index 00000000..dd3cc9cb --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/RoleAggregate/IRoleRepository.cs @@ -0,0 +1,46 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.RoleAggregate; + +/// +/// EN: Repository interface for ApplicationRole aggregate. +/// VI: Interface repository cho ApplicationRole aggregate. +/// +public interface IRoleRepository : IRepository +{ + /// + /// EN: Find role by name. + /// VI: Tìm role theo tên. + /// + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// EN: Find role by ID. + /// VI: Tìm role theo ID. + /// + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get all roles. + /// VI: Lấy tất cả roles. + /// + Task> GetAllAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Add new role. + /// VI: Thêm role mới. + /// + ApplicationRole Add(ApplicationRole role); + + /// + /// EN: Update existing role. + /// VI: Cập nhật role hiện có. + /// + void Update(ApplicationRole role); + + /// + /// EN: Delete role. + /// VI: Xóa role. + /// + void Delete(ApplicationRole role); +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ApplicationUser.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ApplicationUser.cs new file mode 100644 index 00000000..70c7df16 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/ApplicationUser.cs @@ -0,0 +1,178 @@ +using Microsoft.AspNetCore.Identity; +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.UserAggregate; + +/// +/// EN: Application user entity extending ASP.NET Core Identity. +/// VI: Entity user mở rộng từ ASP.NET Core Identity. +/// +public class ApplicationUser : IdentityUser, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private string _firstName = null!; + private string _lastName = null!; + private UserStatus _status = null!; + private DateTime _createdAt; + private DateTime? _lastLoginAt; + + private readonly List _domainEvents = []; + + /// + /// EN: User's first name. + /// VI: Tên người dùng. + /// + public string FirstName => _firstName; + + /// + /// EN: User's last name. + /// VI: Họ người dùng. + /// + public string LastName => _lastName; + + /// + /// EN: User's full name. + /// VI: Tên đầy đủ người dùng. + /// + public string FullName => $"{_firstName} {_lastName}"; + + /// + /// EN: Current status. + /// VI: Trạng thái hiện tại. + /// + public UserStatus Status => _status; + + /// + /// EN: Status ID for EF Core mapping. + /// VI: ID trạng thái cho EF Core mapping. + /// + public int StatusId { get; private set; } + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Last login timestamp. + /// VI: Thời gian đăng nhập cuối. + /// + public DateTime? LastLoginAt => _lastLoginAt; + + /// + /// EN: Domain events. + /// VI: Domain events. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected ApplicationUser() + { + } + + /// + /// EN: Create a new user with required information. + /// VI: Tạo user mới với thông tin bắt buộc. + /// + public ApplicationUser(string email, string firstName, string lastName) : this() + { + if (string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Email cannot be empty", nameof(email)); + if (string.IsNullOrWhiteSpace(firstName)) + throw new ArgumentException("First name cannot be empty", nameof(firstName)); + if (string.IsNullOrWhiteSpace(lastName)) + throw new ArgumentException("Last name cannot be empty", nameof(lastName)); + + Id = Guid.NewGuid(); + Email = email; + UserName = email; + NormalizedEmail = email.ToUpperInvariant(); + NormalizedUserName = email.ToUpperInvariant(); + _firstName = firstName; + _lastName = lastName; + _status = UserStatus.Active; + StatusId = UserStatus.Active.Id; + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Update user profile. + /// VI: Cập nhật thông tin user. + /// + public void UpdateProfile(string firstName, string lastName) + { + if (string.IsNullOrWhiteSpace(firstName)) + throw new ArgumentException("First name cannot be empty", nameof(firstName)); + if (string.IsNullOrWhiteSpace(lastName)) + throw new ArgumentException("Last name cannot be empty", nameof(lastName)); + + _firstName = firstName; + _lastName = lastName; + } + + /// + /// EN: Record successful login. + /// VI: Ghi nhận đăng nhập thành công. + /// + public void RecordLogin() + { + _lastLoginAt = DateTime.UtcNow; + AccessFailedCount = 0; + } + + /// + /// EN: Lock the user account. + /// VI: Khóa tài khoản user. + /// + public void Lock(DateTimeOffset? until = null) + { + _status = UserStatus.Locked; + StatusId = UserStatus.Locked.Id; + LockoutEnd = until ?? DateTimeOffset.MaxValue; + } + + /// + /// EN: Unlock the user account. + /// VI: Mở khóa tài khoản user. + /// + public void Unlock() + { + _status = UserStatus.Active; + StatusId = UserStatus.Active.Id; + LockoutEnd = null; + AccessFailedCount = 0; + } + + /// + /// EN: Disable the user account. + /// VI: Vô hiệu hóa tài khoản user. + /// + public void Disable() + { + _status = UserStatus.Disabled; + StatusId = UserStatus.Disabled.Id; + } + + /// + /// EN: Add domain event. + /// VI: Thêm domain event. + /// + public void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Add(domainEvent); + } + + /// + /// EN: Clear domain events. + /// VI: Xóa domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/IUserRepository.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/IUserRepository.cs new file mode 100644 index 00000000..0be63b7c --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/IUserRepository.cs @@ -0,0 +1,43 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.UserAggregate; + +/// +/// EN: Repository interface for ApplicationUser aggregate. +/// VI: Interface repository cho ApplicationUser aggregate. +/// +public interface IUserRepository : IRepository +{ + /// + /// EN: Find user by email. + /// VI: Tìm user theo email. + /// + Task FindByEmailAsync(string email, CancellationToken cancellationToken = default); + + /// + /// EN: Find user by ID. + /// VI: Tìm user theo ID. + /// + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get all users with pagination. + /// VI: Lấy tất cả users với phân trang. + /// + Task<(IEnumerable Users, int TotalCount)> GetAllAsync( + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); + + /// + /// EN: Add new user. + /// VI: Thêm user mới. + /// + ApplicationUser Add(ApplicationUser user); + + /// + /// EN: Update existing user. + /// VI: Cập nhật user hiện có. + /// + void Update(ApplicationUser user); +} diff --git a/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/UserStatus.cs b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/UserStatus.cs new file mode 100644 index 00000000..a91bd88b --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/AggregatesModel/UserAggregate/UserStatus.cs @@ -0,0 +1,31 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.AggregatesModel.UserAggregate; + +/// +/// EN: User status enumeration. +/// VI: Enumeration trạng thái user. +/// +public class UserStatus : Enumeration +{ + public static readonly UserStatus Active = new(1, nameof(Active)); + public static readonly UserStatus Locked = new(2, nameof(Locked)); + public static readonly UserStatus Disabled = new(3, nameof(Disabled)); + public static readonly UserStatus PendingVerification = new(4, nameof(PendingVerification)); + + public UserStatus(int id, string name) : base(id, name) + { + } + + /// + /// EN: Get all user statuses. + /// VI: Lấy tất cả trạng thái user. + /// + public static IEnumerable GetAll() => + [ + Active, + Locked, + Disabled, + PendingVerification + ]; +} diff --git a/services/iam-service-net/src/IamService.Domain/Events/RoleAssignedDomainEvent.cs b/services/iam-service-net/src/IamService.Domain/Events/RoleAssignedDomainEvent.cs new file mode 100644 index 00000000..660d0046 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Events/RoleAssignedDomainEvent.cs @@ -0,0 +1,23 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.Events; + +/// +/// EN: Domain event raised when a role is assigned to a user. +/// VI: Domain event được raise khi role được gán cho user. +/// +public class RoleAssignedDomainEvent : IDomainEvent +{ + public Guid UserId { get; } + public Guid RoleId { get; } + public string RoleName { get; } + public DateTime OccurredOn { get; } + + public RoleAssignedDomainEvent(Guid userId, Guid roleId, string roleName) + { + UserId = userId; + RoleId = roleId; + RoleName = roleName; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/Events/UserLoggedInDomainEvent.cs b/services/iam-service-net/src/IamService.Domain/Events/UserLoggedInDomainEvent.cs new file mode 100644 index 00000000..20e692f0 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Events/UserLoggedInDomainEvent.cs @@ -0,0 +1,21 @@ +using IamService.Domain.SeedWork; + +namespace IamService.Domain.Events; + +/// +/// EN: Domain event raised when user successfully logs in. +/// VI: Domain event được raise khi user đăng nhập thành công. +/// +public class UserLoggedInDomainEvent : IDomainEvent +{ + public Guid UserId { get; } + public string Email { get; } + public DateTime OccurredOn { get; } + + public UserLoggedInDomainEvent(Guid userId, string email) + { + UserId = userId; + Email = email; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/Events/UserRegisteredDomainEvent.cs b/services/iam-service-net/src/IamService.Domain/Events/UserRegisteredDomainEvent.cs new file mode 100644 index 00000000..b9d0e538 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Events/UserRegisteredDomainEvent.cs @@ -0,0 +1,20 @@ +using IamService.Domain.SeedWork; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.Domain.Events; + +/// +/// EN: Domain event raised when a new user is registered. +/// VI: Domain event được raise khi user mới được đăng ký. +/// +public class UserRegisteredDomainEvent : IDomainEvent +{ + public ApplicationUser User { get; } + public DateTime OccurredOn { get; } + + public UserRegisteredDomainEvent(ApplicationUser user) + { + User = user; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/iam-service-net/src/IamService.Domain/Exceptions/DomainException.cs b/services/iam-service-net/src/IamService.Domain/Exceptions/DomainException.cs new file mode 100644 index 00000000..807b41be --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Exceptions/DomainException.cs @@ -0,0 +1,21 @@ +namespace IamService.Domain.Exceptions; + +/// +/// EN: Base exception for domain errors. +/// VI: Exception cơ sở cho các lỗi domain. +/// +public class DomainException : Exception +{ + public DomainException() + { + } + + public DomainException(string message) : base(message) + { + } + + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/iam-service-net/src/IamService.Domain/Exceptions/InvalidCredentialsException.cs b/services/iam-service-net/src/IamService.Domain/Exceptions/InvalidCredentialsException.cs new file mode 100644 index 00000000..05ee26f7 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Exceptions/InvalidCredentialsException.cs @@ -0,0 +1,18 @@ +namespace IamService.Domain.Exceptions; + +/// +/// EN: Exception thrown for invalid credentials. +/// VI: Exception khi thông tin đăng nhập không hợp lệ. +/// +public class InvalidCredentialsException : DomainException +{ + public InvalidCredentialsException() + : base("Invalid email or password") + { + } + + public InvalidCredentialsException(string message) + : base(message) + { + } +} diff --git a/services/iam-service-net/src/IamService.Domain/Exceptions/UserLockedException.cs b/services/iam-service-net/src/IamService.Domain/Exceptions/UserLockedException.cs new file mode 100644 index 00000000..349baddd --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/Exceptions/UserLockedException.cs @@ -0,0 +1,18 @@ +namespace IamService.Domain.Exceptions; + +/// +/// EN: Exception thrown when user account is locked. +/// VI: Exception khi tài khoản user bị khóa. +/// +public class UserLockedException : DomainException +{ + public UserLockedException() + : base("User account is locked") + { + } + + public UserLockedException(string message) + : base(message) + { + } +} diff --git a/services/iam-service-net/src/IamService.Domain/IamService.Domain.csproj b/services/iam-service-net/src/IamService.Domain/IamService.Domain.csproj new file mode 100644 index 00000000..9712c1d9 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/IamService.Domain.csproj @@ -0,0 +1,17 @@ + + + + IamService.Domain + IamService.Domain + Domain layer containing core business logic and entities + + + + + + + + + + + diff --git a/services/iam-service-net/src/IamService.Domain/SeedWork/Entity.cs b/services/iam-service-net/src/IamService.Domain/SeedWork/Entity.cs new file mode 100644 index 00000000..d1ebba03 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/SeedWork/Entity.cs @@ -0,0 +1,102 @@ +using MediatR; + +namespace IamService.Domain.SeedWork; + +/// +/// EN: Base class for all domain entities. +/// VI: Lớp cơ sở cho tất cả các entity trong domain. +/// +public abstract class Entity +{ + private int? _requestedHashCode; + private Guid _id; + private List _domainEvents = new(); + + /// + /// EN: Unique identifier for the entity. + /// VI: Định danh duy nhất cho entity. + /// + public virtual Guid Id + { + get => _id; + protected set => _id = value; + } + + /// + /// EN: Domain events raised by this entity. + /// VI: Các domain event được phát ra bởi entity này. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Add a domain event to be dispatched. + /// VI: Thêm một domain event để dispatch. + /// + public void AddDomainEvent(INotification eventItem) + { + _domainEvents.Add(eventItem); + } + + /// + /// EN: Remove a domain event. + /// VI: Xóa một domain event. + /// + public void RemoveDomainEvent(INotification eventItem) + { + _domainEvents.Remove(eventItem); + } + + /// + /// EN: Clear all domain events. + /// VI: Xóa tất cả domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } + + /// + /// EN: Check if entity is transient (not persisted yet). + /// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không. + /// + public bool IsTransient() + { + return Id == default; + } + + public override bool Equals(object? obj) + { + if (obj is not Entity item) + return false; + + if (ReferenceEquals(this, item)) + return true; + + if (GetType() != item.GetType()) + return false; + + if (item.IsTransient() || IsTransient()) + return false; + + return item.Id == Id; + } + + public override int GetHashCode() + { + if (IsTransient()) + return base.GetHashCode(); + + _requestedHashCode ??= Id.GetHashCode() ^ 31; + return _requestedHashCode.Value; + } + + public static bool operator ==(Entity? left, Entity? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } +} diff --git a/services/iam-service-net/src/IamService.Domain/SeedWork/Enumeration.cs b/services/iam-service-net/src/IamService.Domain/SeedWork/Enumeration.cs new file mode 100644 index 00000000..ff9d7c7a --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/SeedWork/Enumeration.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace IamService.Domain.SeedWork; + +/// +/// EN: Base class for enumeration classes (type-safe enum pattern). +/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu). +/// +/// +/// EN: This provides a type-safe alternative to enums with additional functionality +/// like validation, parsing, and rich behavior. +/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung +/// như validation, parsing, và hành vi phong phú. +/// +public abstract class Enumeration : IComparable +{ + /// + /// EN: The name of the enumeration value. + /// VI: Tên của giá trị enumeration. + /// + public string Name { get; private set; } + + /// + /// EN: The unique identifier of the enumeration value. + /// VI: Định danh duy nhất của giá trị enumeration. + /// + public int Id { get; private set; } + + protected Enumeration(int id, string name) => (Id, Name) = (id, name); + + public override string ToString() => Name; + + /// + /// EN: Get all enumeration values of a given type. + /// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước. + /// + public static IEnumerable GetAll() where T : Enumeration => + typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(f => f.GetValue(null)) + .Cast(); + + public override bool Equals(object? obj) + { + if (obj is not Enumeration otherValue) + return false; + + var typeMatches = GetType() == obj.GetType(); + var valueMatches = Id.Equals(otherValue.Id); + + return typeMatches && valueMatches; + } + + public override int GetHashCode() => Id.GetHashCode(); + + /// + /// EN: Get absolute difference between two enumeration values. + /// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration. + /// + public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue) + { + return Math.Abs(firstValue.Id - secondValue.Id); + } + + /// + /// EN: Parse an integer ID to the corresponding enumeration value. + /// VI: Parse một ID integer thành giá trị enumeration tương ứng. + /// + public static T FromValue(int value) where T : Enumeration + { + var matchingItem = Parse(value, "value", item => item.Id == value); + return matchingItem; + } + + /// + /// EN: Parse a display name to the corresponding enumeration value. + /// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng. + /// + public static T FromDisplayName(string displayName) where T : Enumeration + { + var matchingItem = Parse(displayName, "display name", item => item.Name == displayName); + return matchingItem; + } + + private static T Parse(TValue value, string description, Func predicate) where T : Enumeration + { + var matchingItem = GetAll().FirstOrDefault(predicate); + + if (matchingItem is null) + throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}"); + + return matchingItem; + } + + public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id); +} diff --git a/services/iam-service-net/src/IamService.Domain/SeedWork/IAggregateRoot.cs b/services/iam-service-net/src/IamService.Domain/SeedWork/IAggregateRoot.cs new file mode 100644 index 00000000..71df0862 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/SeedWork/IAggregateRoot.cs @@ -0,0 +1,15 @@ +namespace IamService.Domain.SeedWork; + +/// +/// EN: Marker interface for aggregate roots. +/// VI: Interface đánh dấu cho aggregate roots. +/// +/// +/// EN: Aggregate roots are the entry points to aggregates and are the only objects +/// that outside code should hold references to. +/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất +/// mà code bên ngoài nên giữ tham chiếu đến. +/// +public interface IAggregateRoot +{ +} diff --git a/services/iam-service-net/src/IamService.Domain/SeedWork/IDomainEvent.cs b/services/iam-service-net/src/IamService.Domain/SeedWork/IDomainEvent.cs new file mode 100644 index 00000000..40735df3 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/SeedWork/IDomainEvent.cs @@ -0,0 +1,12 @@ +using MediatR; + +namespace IamService.Domain.SeedWork; + +/// +/// EN: Marker interface for domain events. +/// VI: Interface đánh dấu cho domain events. +/// +public interface IDomainEvent : INotification +{ + DateTime OccurredOn { get; } +} diff --git a/services/iam-service-net/src/IamService.Domain/SeedWork/IRepository.cs b/services/iam-service-net/src/IamService.Domain/SeedWork/IRepository.cs new file mode 100644 index 00000000..d113579e --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/SeedWork/IRepository.cs @@ -0,0 +1,15 @@ +namespace IamService.Domain.SeedWork; + +/// +/// EN: Generic repository interface for aggregate roots. +/// VI: Interface repository generic cho aggregate roots. +/// +/// EN: The aggregate root type / VI: Kiểu aggregate root +public interface IRepository where T : IAggregateRoot +{ + /// + /// EN: The unit of work for this repository. + /// VI: Unit of work cho repository này. + /// + IUnitOfWork UnitOfWork { get; } +} diff --git a/services/iam-service-net/src/IamService.Domain/SeedWork/IUnitOfWork.cs b/services/iam-service-net/src/IamService.Domain/SeedWork/IUnitOfWork.cs new file mode 100644 index 00000000..97299b97 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/SeedWork/IUnitOfWork.cs @@ -0,0 +1,30 @@ +namespace IamService.Domain.SeedWork; + +/// +/// EN: Unit of Work pattern interface. +/// VI: Interface cho Unit of Work pattern. +/// +/// +/// EN: Maintains a list of objects affected by a business transaction +/// and coordinates the writing out of changes. +/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ +/// và điều phối việc ghi các thay đổi. +/// +public interface IUnitOfWork : IDisposable +{ + /// + /// EN: Save all changes made in this unit of work. + /// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: Number of entities written / VI: Số entity đã ghi + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Save all changes and dispatch domain events. + /// VI: Lưu tất cả thay đổi và dispatch domain events. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: True if successful / VI: True nếu thành công + Task SaveEntitiesAsync(CancellationToken cancellationToken = default); +} diff --git a/services/iam-service-net/src/IamService.Domain/SeedWork/ValueObject.cs b/services/iam-service-net/src/IamService.Domain/SeedWork/ValueObject.cs new file mode 100644 index 00000000..03128746 --- /dev/null +++ b/services/iam-service-net/src/IamService.Domain/SeedWork/ValueObject.cs @@ -0,0 +1,53 @@ +namespace IamService.Domain.SeedWork; + +/// +/// EN: Base class for Value Objects following DDD patterns. +/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD. +/// +/// +/// EN: Value objects are immutable and compared by their values, not identity. +/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh. +/// +public abstract class ValueObject +{ + /// + /// EN: Get the atomic values that make up this value object. + /// VI: Lấy các giá trị nguyên tử tạo nên value object này. + /// + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj is null || obj.GetType() != GetType()) + return false; + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + return GetEqualityComponents() + .Select(x => x?.GetHashCode() ?? 0) + .Aggregate((x, y) => x ^ y); + } + + public static bool operator ==(ValueObject? left, ValueObject? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(ValueObject? left, ValueObject? right) + { + return !(left == right); + } + + /// + /// EN: Create a copy of this value object with modifications. + /// VI: Tạo bản sao của value object này với các thay đổi. + /// + protected ValueObject GetCopy() + { + return (ValueObject)MemberwiseClone(); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..3c2fd982 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs @@ -0,0 +1,143 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.AggregatesModel.RoleAggregate; +using IamService.Domain.SeedWork; +using IamService.Infrastructure.Repositories; + +namespace IamService.Infrastructure; + +/// +/// EN: Dependency injection extensions for Infrastructure layer. +/// VI: Dependency injection extensions cho Infrastructure layer. +/// +public static class DependencyInjection +{ + /// + /// EN: Add Infrastructure services to DI container. + /// VI: Thêm Infrastructure services vào DI container. + /// + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // EN: Get database connection string + // VI: Lấy database connection string + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Database connection string not configured"); + + // EN: Add DbContext with PostgreSQL + // VI: Thêm DbContext với PostgreSQL + services.AddDbContext(options => + { + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(IamServiceContext).Assembly.FullName); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 3, + maxRetryDelay: TimeSpan.FromSeconds(10), + errorCodesToAdd: null); + }); + + // EN: Use OpenIddict EF Core stores + // VI: Sử dụng OpenIddict EF Core stores + options.UseOpenIddict(); + }); + + // EN: Add ASP.NET Core Identity + // VI: Thêm ASP.NET Core Identity + services.AddIdentity(options => + { + // EN: Password settings + // VI: Cài đặt mật khẩu + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = true; + options.Password.RequiredLength = 8; + + // EN: Lockout settings + // VI: Cài đặt khóa tài khoản + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.AllowedForNewUsers = true; + + // EN: User settings + // VI: Cài đặt user + options.User.RequireUniqueEmail = true; + options.SignIn.RequireConfirmedEmail = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + // EN: Configure OpenIddict + // VI: Cấu hình OpenIddict + services.AddOpenIddict() + // EN: Register the OpenIddict core components + // VI: Đăng ký OpenIddict core components + .AddCore(options => + { + options.UseEntityFrameworkCore() + .UseDbContext(); + }) + // EN: Register the OpenIddict server components + // VI: Đăng ký OpenIddict server components + .AddServer(options => + { + // EN: Enable token endpoints + // VI: Bật token endpoints + options.SetTokenEndpointUris("/connect/token") + .SetUserinfoEndpointUris("/connect/userinfo") + .SetIntrospectionEndpointUris("/connect/introspect") + .SetRevocationEndpointUris("/connect/revoke"); + + // EN: Enable flows + // VI: Bật flows + options.AllowPasswordFlow() + .AllowRefreshTokenFlow() + .AllowClientCredentialsFlow(); + + // EN: Register scopes + // VI: Đăng ký scopes + options.RegisterScopes("openid", "profile", "email", "roles", "api"); + + // EN: Token lifetimes + // VI: Thời hạn token + options.SetAccessTokenLifetime(TimeSpan.FromMinutes(15)) + .SetRefreshTokenLifetime(TimeSpan.FromDays(7)); + + // EN: Development settings - Disable HTTPS requirement for local dev + // VI: Cài đặt development - Tắt yêu cầu HTTPS cho dev local + options.AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate(); + + // EN: Accept anonymous clients (for password flow) + // VI: Chấp nhận anonymous clients (cho password flow) + options.AcceptAnonymousClients(); + + // EN: Disable client authentication + // VI: Tắt client authentication (for development) + options.UseAspNetCore() + .EnableTokenEndpointPassthrough() + .EnableUserinfoEndpointPassthrough(); + }) + // EN: Register the OpenIddict validation components + // VI: Đăng ký OpenIddict validation components + .AddValidation(options => + { + options.UseLocalServer(); + options.UseAspNetCore(); + }); + + // EN: Register repositories + // VI: Đăng ký repositories + services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + + return services; + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj b/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj new file mode 100644 index 00000000..de68ed06 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj @@ -0,0 +1,43 @@ + + + + IamService.Infrastructure + IamService.Infrastructure + Infrastructure layer for data access and external services + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs b/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs new file mode 100644 index 00000000..0a299a83 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs @@ -0,0 +1,178 @@ +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.AggregatesModel.RoleAggregate; +using IamService.Domain.SeedWork; + +namespace IamService.Infrastructure; + +/// +/// EN: Database context for IAM Service with Identity and OpenIddict support. +/// VI: Database context cho IAM Service với Identity và OpenIddict support. +/// +public class IamServiceContext : IdentityDbContext, IUnitOfWork +{ + private readonly IMediator _mediator; + private IDbContextTransaction? _currentTransaction; + + public IamServiceContext(DbContextOptions options) + : base(options) + { + _mediator = null!; + } + + public IamServiceContext(DbContextOptions options, IMediator mediator) + : base(options) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + } + + /// + /// EN: User statuses table. + /// VI: Bảng trạng thái user. + /// + public DbSet UserStatuses { get; set; } = null!; + + /// + /// EN: Check if there's an active transaction. + /// VI: Kiểm tra xem có transaction đang hoạt động không. + /// + public bool HasActiveTransaction => _currentTransaction != null; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // EN: Apply entity configurations + // VI: Áp dụng entity configurations + modelBuilder.ApplyConfigurationsFromAssembly(typeof(IamServiceContext).Assembly); + + // EN: Configure Identity tables with custom names + // VI: Cấu hình bảng Identity với tên tùy chỉnh + modelBuilder.Entity().ToTable("users"); + modelBuilder.Entity().ToTable("roles"); + modelBuilder.Entity>().ToTable("user_roles"); + modelBuilder.Entity>().ToTable("user_claims"); + modelBuilder.Entity>().ToTable("user_logins"); + modelBuilder.Entity>().ToTable("user_tokens"); + modelBuilder.Entity>().ToTable("role_claims"); + + // EN: Seed UserStatus enumeration + // VI: Seed UserStatus enumeration + modelBuilder.Entity().ToTable("user_statuses"); + modelBuilder.Entity().HasData( + UserStatus.Active, + UserStatus.Locked, + UserStatus.Disabled, + UserStatus.PendingVerification + ); + + // EN: Configure OpenIddict entities + // VI: Cấu hình OpenIddict entities + modelBuilder.UseOpenIddict(); + } + + /// + /// EN: Save changes and dispatch domain events. + /// VI: Lưu thay đổi và dispatch domain events. + /// + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + // EN: Dispatch domain events before saving + // VI: Dispatch domain events trước khi lưu + await DispatchDomainEventsAsync(cancellationToken); + + await base.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// EN: Begin a new transaction. + /// VI: Bắt đầu transaction mới. + /// + public async Task BeginTransactionAsync() + { + if (_currentTransaction != null) return null; + + _currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); + + return _currentTransaction; + } + + /// + /// EN: Commit the current transaction. + /// VI: Commit transaction hiện tại. + /// + public async Task CommitTransactionAsync(IDbContextTransaction transaction) + { + if (transaction == null) throw new ArgumentNullException(nameof(transaction)); + if (transaction != _currentTransaction) throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current"); + + try + { + await SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + await RollbackTransactionAsync(); + throw; + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Rollback the current transaction. + /// VI: Rollback transaction hiện tại. + /// + public async Task RollbackTransactionAsync() + { + try + { + if (_currentTransaction != null) + { + await _currentTransaction.RollbackAsync(); + } + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken) + { + if (_mediator == null) return; + + var domainEntities = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .ToList(); + + var domainEvents = domainEntities + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent, cancellationToken); + } + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Idempotency/ClientRequest.cs b/services/iam-service-net/src/IamService.Infrastructure/Idempotency/ClientRequest.cs new file mode 100644 index 00000000..c6efd199 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Idempotency/ClientRequest.cs @@ -0,0 +1,26 @@ +namespace IamService.Infrastructure.Idempotency; + +/// +/// EN: Entity for tracking client requests to ensure idempotency. +/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency. +/// +public class ClientRequest +{ + /// + /// EN: Unique request identifier. + /// VI: Định danh request duy nhất. + /// + public Guid Id { get; set; } + + /// + /// EN: Name of the command/request type. + /// VI: Tên của loại command/request. + /// + public string Name { get; set; } = null!; + + /// + /// EN: Timestamp when the request was received. + /// VI: Thời điểm request được nhận. + /// + public DateTime Time { get; set; } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Idempotency/IRequestManager.cs b/services/iam-service-net/src/IamService.Infrastructure/Idempotency/IRequestManager.cs new file mode 100644 index 00000000..44ec7543 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Idempotency/IRequestManager.cs @@ -0,0 +1,24 @@ +namespace IamService.Infrastructure.Idempotency; + +/// +/// EN: Interface for managing client request idempotency. +/// VI: Interface để quản lý idempotency của client requests. +/// +public interface IRequestManager +{ + /// + /// EN: Check if a request with the given ID exists. + /// VI: Kiểm tra xem request với ID cho trước có tồn tại không. + /// + /// EN: Request ID / VI: ID của request + /// EN: True if exists / VI: True nếu tồn tại + Task ExistAsync(Guid id); + + /// + /// EN: Create a new request record for tracking. + /// VI: Tạo bản ghi request mới để theo dõi. + /// + /// EN: Command type / VI: Loại command + /// EN: Request ID / VI: ID của request + Task CreateRequestForCommandAsync(Guid id); +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Idempotency/RequestManager.cs b/services/iam-service-net/src/IamService.Infrastructure/Idempotency/RequestManager.cs new file mode 100644 index 00000000..378b912a --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Idempotency/RequestManager.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace IamService.Infrastructure.Idempotency; + +/// +/// EN: Implementation of request manager for idempotency. +/// VI: Triển khai request manager cho idempotency. +/// +public class RequestManager : IRequestManager +{ + private readonly IamServiceContext _context; + + public RequestManager(IamServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task ExistAsync(Guid id) + { + var request = await _context + .FindAsync(id); + + return request != null; + } + + /// + public async Task CreateRequestForCommandAsync(Guid id) + { + var exists = await ExistAsync(id); + + var request = exists + ? throw new InvalidOperationException($"Request with {id} already exists") + : new ClientRequest + { + Id = id, + Name = typeof(T).Name, + Time = DateTime.UtcNow + }; + + _context.Add(request); + + await _context.SaveChangesAsync(); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Repositories/RoleRepository.cs b/services/iam-service-net/src/IamService.Infrastructure/Repositories/RoleRepository.cs new file mode 100644 index 00000000..2a6e220f --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Repositories/RoleRepository.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using IamService.Domain.AggregatesModel.RoleAggregate; +using IamService.Domain.SeedWork; + +namespace IamService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for ApplicationRole aggregate. +/// VI: Repository implementation cho ApplicationRole aggregate. +/// +public class RoleRepository : IRoleRepository +{ + private readonly IamServiceContext _context; + + public RoleRepository(IamServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public IUnitOfWork UnitOfWork => _context; + + /// + /// EN: Find role by name. + /// VI: Tìm role theo tên. + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + return await _context.Roles + .FirstOrDefaultAsync(r => r.NormalizedName == name.ToUpperInvariant(), cancellationToken); + } + + /// + /// EN: Find role by ID. + /// VI: Tìm role theo ID. + /// + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Roles + .FirstOrDefaultAsync(r => r.Id == id, cancellationToken); + } + + /// + /// EN: Get all roles. + /// VI: Lấy tất cả roles. + /// + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _context.Roles + .OrderBy(r => r.Name) + .ToListAsync(cancellationToken); + } + + /// + /// EN: Add new role. + /// VI: Thêm role mới. + /// + public ApplicationRole Add(ApplicationRole role) + { + return _context.Roles.Add(role).Entity; + } + + /// + /// EN: Update existing role. + /// VI: Cập nhật role hiện có. + /// + public void Update(ApplicationRole role) + { + _context.Entry(role).State = EntityState.Modified; + } + + /// + /// EN: Delete role. + /// VI: Xóa role. + /// + public void Delete(ApplicationRole role) + { + _context.Roles.Remove(role); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Repositories/UserRepository.cs b/services/iam-service-net/src/IamService.Infrastructure/Repositories/UserRepository.cs new file mode 100644 index 00000000..791948a3 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Repositories/UserRepository.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Domain.SeedWork; + +namespace IamService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for ApplicationUser aggregate. +/// VI: Repository implementation cho ApplicationUser aggregate. +/// +public class UserRepository : IUserRepository +{ + private readonly IamServiceContext _context; + + public UserRepository(IamServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public IUnitOfWork UnitOfWork => _context; + + /// + /// EN: Find user by email. + /// VI: Tìm user theo email. + /// + public async Task FindByEmailAsync(string email, CancellationToken cancellationToken = default) + { + return await _context.Users + .FirstOrDefaultAsync(u => u.NormalizedEmail == email.ToUpperInvariant(), cancellationToken); + } + + /// + /// EN: Find user by ID. + /// VI: Tìm user theo ID. + /// + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Users + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + /// + /// EN: Get all users with pagination. + /// VI: Lấy tất cả users với phân trang. + /// + public async Task<(IEnumerable Users, int TotalCount)> GetAllAsync( + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default) + { + var query = _context.Users.AsQueryable(); + + var totalCount = await query.CountAsync(cancellationToken); + + var users = await query + .OrderByDescending(u => u.CreatedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (users, totalCount); + } + + /// + /// EN: Add new user. + /// VI: Thêm user mới. + /// + public ApplicationUser Add(ApplicationUser user) + { + return _context.Users.Add(user).Entity; + } + + /// + /// EN: Update existing user. + /// VI: Cập nhật user hiện có. + /// + public void Update(ApplicationUser user) + { + _context.Entry(user).State = EntityState.Modified; + } +} diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/SamplesControllerTests.cs b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/SamplesControllerTests.cs new file mode 100644 index 00000000..03b8dae0 --- /dev/null +++ b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/SamplesControllerTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace IamService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for Samples API endpoints. +/// VI: Functional tests cho các endpoints API Samples. +/// +public class SamplesControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public SamplesControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task GetSamples_ShouldReturnOkWithEmptyList() + { + // Act + var response = await _client.GetAsync("/api/v1/samples"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadFromJsonAsync>>(); + content?.Success.Should().BeTrue(); + } + + [Fact] + public async Task CreateSample_WithValidData_ShouldReturnCreated() + { + // Arrange + var request = new { Name = "Test Sample", Description = "Test Description" }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/samples", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var content = await response.Content.ReadFromJsonAsync>(); + content?.Success.Should().BeTrue(); + content?.Data?.Id.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetSample_WithInvalidId_ShouldReturnNotFound() + { + // Arrange + var invalidId = Guid.NewGuid(); + + // Act + var response = await _client.GetAsync($"/api/v1/samples/{invalidId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task HealthCheck_ShouldReturnHealthy() + { + // Act + var response = await _client.GetAsync("/health/live"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // EN: Helper DTOs for deserialization + // VI: Helper DTOs để deserialize + private record ApiResponse(bool Success, T? Data); + private record CreateSampleResult(Guid Id); +} diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs b/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 00000000..e4d801e9 --- /dev/null +++ b/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using IamService.Infrastructure; + +namespace IamService.FunctionalTests; + +/// +/// EN: Custom WebApplicationFactory for functional tests. +/// VI: WebApplicationFactory tùy chỉnh cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // EN: Remove the existing DbContext registration + // VI: Xóa đăng ký DbContext hiện tại + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (descriptor != null) + { + services.Remove(descriptor); + } + + // EN: Remove DbContext service + // VI: Xóa DbContext service + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(IamServiceContext)); + + if (dbContextDescriptor != null) + { + services.Remove(dbContextDescriptor); + } + + // EN: Add in-memory database for testing + // VI: Thêm in-memory database để test + services.AddDbContext(options => + { + options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); + }); + + // EN: Ensure database is created with seed data + // VI: Đảm bảo database được tạo với seed data + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + }); + } +} diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/IamService.FunctionalTests.csproj b/services/iam-service-net/tests/IamService.FunctionalTests/IamService.FunctionalTests.csproj new file mode 100644 index 00000000..a8aef3cd --- /dev/null +++ b/services/iam-service-net/tests/IamService.FunctionalTests/IamService.FunctionalTests.csproj @@ -0,0 +1,38 @@ + + + + IamService.FunctionalTests + IamService.FunctionalTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/services/iam-service-net/tests/IamService.UnitTests/IamService.UnitTests.csproj b/services/iam-service-net/tests/IamService.UnitTests/IamService.UnitTests.csproj new file mode 100644 index 00000000..8bd20262 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/IamService.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + IamService.UnitTests + IamService.UnitTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + +