feat(authentication): Implement email verification, two-factor authentication, and social login features

- Added endpoints for sending and confirming email verification, enhancing user account security.
- Integrated two-factor authentication (2FA) with TOTP support, including enabling, verifying, and disabling 2FA.
- Implemented social login functionality for Google and Facebook, allowing users to authenticate using their existing accounts.
- Updated dependency injection to include services for email, 2FA, and social login.
- Enhanced documentation to reflect new features and usage examples for email verification and 2FA.
This commit is contained in:
Ho Ngoc Hai
2026-01-12 23:07:53 +07:00
parent 83b007c8ef
commit 928a22fe3e
124 changed files with 9569 additions and 24 deletions

11
NOTE.MD
View File

@@ -3,13 +3,4 @@
- Role/Permission Management APIs - CRUD roles
- Email Verification - Confirm email
- 2FA/MFA - Two-factor authentication
- Social Login - Google, Facebook, etc.
Có Cached chưa
Đề xuất cần implement:
Redis Connection - Đăng ký IConnectionMultiplexer trong DI
Distributed Caching Service - Sử dụng IDistributedCache
Token Caching - Cache refresh tokens, blacklist tokens
Session Caching - User sessions và permissions
- Social Login - Google, Facebook, etc.

View File

@@ -134,6 +134,71 @@ services:
- "traefik.http.services.iam-service.loadbalancer.healthcheck.path=/health"
- "traefik.http.services.iam-service.loadbalancer.healthcheck.interval=10s"
# Storage Service .NET - File Storage Management
storage-service:
build:
context: ../..
dockerfile: services/storage-service-net/Dockerfile
container_name: storage-service-local
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=${STORAGE_DATABASE_URL:-Host=localhost;Port=5432;Database=storage_db;Username=postgres;Password=postgres}
- Storage__Provider=${STORAGE_PROVIDER:-minio}
- Storage__DefaultBucket=${STORAGE_DEFAULT_BUCKET:-storage}
- Storage__MinIO__Endpoint=minio:9000
- Storage__MinIO__AccessKey=${MINIO_ACCESS_KEY:-minioadmin}
- Storage__MinIO__SecretKey=${MINIO_SECRET_KEY:-minioadmin}
- Storage__MinIO__UseSSL=false
- IamService__BaseUrl=http://iam-service:5001
- IamService__ServiceName=storage-service
ports:
- "5002:8080"
depends_on:
redis:
condition: service_healthy
minio:
condition: service_healthy
traefik:
condition: service_started
networks:
- microservices-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "traefik.enable=true"
- "traefik.http.routers.storage-service.rule=PathPrefix(`/api/v1/files`) || PathPrefix(`/api/v1/quota`)"
- "traefik.http.routers.storage-service.entrypoints=web"
- "traefik.http.services.storage-service.loadbalancer.server.port=8080"
- "traefik.http.services.storage-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.storage-service.loadbalancer.healthcheck.interval=10s"
# MinIO - S3-compatible Object Storage
minio:
image: minio/minio:latest
container_name: minio-local
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
ports:
- "9000:9000" # API port
- "9001:9001" # Console port
volumes:
- minio_data:/data
networks:
- microservices-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
# ===========================================================================
# FRONTEND APPLICATIONS (Temporarily disabled)
# ===========================================================================
@@ -237,6 +302,8 @@ services:
volumes:
redis_data:
driver: local
minio_data:
driver: local
# prometheus_data:
# driver: local
# grafana_data:

View File

@@ -89,10 +89,24 @@ sequenceDiagram
- Refresh: Database SHA-256 hash
- Rotation: New refresh token on each use
**4. MFA Support**:
- TOTP (Time-based One-Time Password)
- Backup codes (10 single-use)
- Recovery email verification
**4. MFA Support (Two-Factor Authentication)**:
- TOTP (Time-based One-Time Password) using RFC 6238
- QR code generation for authenticator apps (Google Authenticator, Authy)
- Recovery codes (10 single-use codes)
- Secret key storage: UserManager.SetAuthenticationTokenAsync
**5. Email Verification**:
- SMTP-based verification emails via MailKit
- Token generation using UserManager.GenerateEmailConfirmationTokenAsync
- Verification link with token and userId
- EmailConfirmed flag set true upon confirmation
**6. Social Login (OAuth2 Providers)**:
- Google OAuth 2.0 integration
- Facebook OAuth integration
- Account linking for existing users (by email match)
- Auto email confirmation for social logins
- Provider info stored via UserManager.AddLoginAsync
## Authorization Model

View File

@@ -4,10 +4,13 @@
## Overview
The IAM Service provides OAuth2/OIDC authentication using OpenIddict:
The IAM Service provides OAuth2/OIDC authentication using Duende IdentityServer:
- **Password Grant** - User login with email/password
- **Refresh Token** - Token renewal without re-authentication
- **Client Credentials** - Service-to-service authentication
- **Email Verification** - SMTP-based email confirmation
- **Two-Factor Authentication (2FA)** - TOTP with QR code and recovery codes
- **Social Login** - Google and Facebook OAuth integration
## Quick Start
@@ -142,6 +145,29 @@ curl -X POST http://localhost:5001/connect/token \
| `POST` | `/api/v1/auth/change-password` | Change password (auth required) |
| `POST` | `/api/v1/auth/logout` | Revoke tokens (auth required) |
### Email Verification
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/auth/send-verification-email` | Send email verification link (auth required) |
| `POST` | `/api/v1/auth/confirm-email` | Confirm email with token |
### Two-Factor Authentication (2FA)
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/auth/2fa/enable` | Enable 2FA (get QR code) (auth required) |
| `POST` | `/api/v1/auth/2fa/verify` | Verify TOTP code & activate (auth required) |
| `POST` | `/api/v1/auth/2fa/disable` | Disable 2FA (auth required) |
### Social Login
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/auth/external-login/{provider}` | Initiate OAuth flow (Google/Facebook) |
| `GET` | `/api/v1/auth/external-callback` | Handle OAuth callback |
| `GET` | `/api/v1/auth/linked-accounts` | Get linked OAuth providers (auth required) |
### User Management
| Method | Endpoint | Description |
@@ -197,6 +223,94 @@ client.DefaultRequestHeaders.Authorization =
var user = await client.GetFromJsonAsync<UserDto>("/api/v1/users/me");
```
## Email Verification
### Send Verification Email
```bash
curl -X POST http://localhost:5001/api/v1/auth/send-verification-email \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
```
### Confirm Email
```bash
curl -X POST http://localhost:5001/api/v1/auth/confirm-email \
-H "Content-Type: application/json" \
-d '{"userId": "user-guid", "token": "confirmation-token"}'
```
## Two-Factor Authentication (2FA)
### Enable 2FA
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/enable \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
**Response:**
```json
{
"success": true,
"data": {
"secretKey": "JBSWY3DPEHPK3PXP",
"qrCodeBase64": "data:image/png;base64,...",
"recoveryCodes": ["code1", "code2", "code3"]
}
}
```
### Verify 2FA Code
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/verify \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
```
### Disable 2FA
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/disable \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
```
## Social Login
### Initiate OAuth Flow
Redirect user to:
```
GET http://localhost:5001/api/v1/auth/external-login/Google?returnUrl=http://your-app/callback
GET http://localhost:5001/api/v1/auth/external-login/Facebook?returnUrl=http://your-app/callback
```
### Get Linked Accounts
```bash
curl http://localhost:5001/api/v1/auth/linked-accounts \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
**Response:**
```json
{
"success": true,
"data": {
"linkedProviders": [
{"provider": "Google", "providerDisplayName": "Google"},
{"provider": "Facebook", "providerDisplayName": "Facebook"}
]
}
}
```
## Error Responses
### Common Errors

View File

@@ -112,10 +112,24 @@ sequenceDiagram
- Refresh: Database SHA-256 hash
- Rotation: Refresh token mới mỗi lần sử dụng
**4. MFA Support**:
- TOTP (Time-based One-Time Password)
- Backup codes (10 single-use)
- Recovery email verification
**4. MFA Support (Xác thực Hai yếu tố)**:
- TOTP (RFC 6238) cho authenticator apps
- QR code để thiết lập (Google Authenticator, Authy)
- Recovery codes (10 mã dùng một lần)
- Secret key lưu qua UserManager.SetAuthenticationTokenAsync
**5. Email Verification (Xác minh Email)**:
- Gửi email xác minh qua SMTP (MailKit)
- Token generation: UserManager.GenerateEmailConfirmationTokenAsync
- Link xác minh với token và userId
- Đặt EmailConfirmed = true khi xác nhận
**6. Social Login (Đăng nhập Mạng xã hội)**:
- Tích hợp Google OAuth 2.0
- Tích hợp Facebook OAuth
- Liên kết tài khoản cho users hiện có (theo email)
- Tự động xác nhận email cho social logins
- Lưu provider info qua UserManager.AddLoginAsync
### EN: Authentication Details
@@ -135,10 +149,24 @@ sequenceDiagram
- Refresh: Database SHA-256 hash
- Rotation: New refresh token on each use
**4. MFA Support**:
- TOTP (Time-based One-Time Password)
- Backup codes (10 single-use)
- Recovery email verification
**4. MFA Support (Two-Factor Authentication)**:
- TOTP (Time-based One-Time Password) using RFC 6238
- QR code generation for authenticator apps (Google Authenticator, Authy)
- Recovery codes (10 single-use codes)
- Secret key storage: UserManager.SetAuthenticationTokenAsync
**5. Email Verification**:
- SMTP-based verification emails via MailKit
- Token generation using UserManager.GenerateEmailConfirmationTokenAsync
- Verification link with token and userId
- EmailConfirmed flag set true upon confirmation
**6. Social Login (OAuth2 Providers)**:
- Google OAuth 2.0 integration
- Facebook OAuth integration
- Account linking for existing users (by email match)
- Auto email confirmation for social logins
- Provider info stored via UserManager.AddLoginAsync
## Mô hình Phân quyền / Authorization Model

View File

@@ -4,10 +4,13 @@
## Tổng Quan
IAM Service cung cấp xác thực OAuth2/OIDC sử dụng OpenIddict:
IAM Service cung cấp xác thực OAuth2/OIDC sử dụng Duende IdentityServer:
- **Password Grant** - Đăng nhập user với email/password
- **Refresh Token** - Làm mới token mà không cần xác thực lại
- **Client Credentials** - Xác thực service-to-service
- **Email Verification** - Xác minh email qua SMTP
- **Two-Factor Authentication (2FA)** - TOTP với QR code và recovery codes
- **Social Login** - Tích hợp OAuth Google và Facebook
## Bắt Đầu Nhanh
@@ -142,6 +145,29 @@ curl -X POST http://localhost:5001/connect/token \
| `POST` | `/api/v1/auth/change-password` | Đổi mật khẩu (cần auth) |
| `POST` | `/api/v1/auth/logout` | Thu hồi tokens (cần auth) |
### Xác Minh Email
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `POST` | `/api/v1/auth/send-verification-email` | Gửi link xác minh email (cần auth) |
| `POST` | `/api/v1/auth/confirm-email` | Xác nhận email với token |
### Xác Thực Hai Yếu Tố (2FA)
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `POST` | `/api/v1/auth/2fa/enable` | Bật 2FA (lấy QR code) (cần auth) |
| `POST` | `/api/v1/auth/2fa/verify` | Xác minh mã TOTP & kích hoạt (cần auth) |
| `POST` | `/api/v1/auth/2fa/disable` | Tắt 2FA (cần auth) |
### Đăng Nhập Mạng Xã Hội
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/auth/external-login/{provider}` | Khởi tạo OAuth flow (Google/Facebook) |
| `GET` | `/api/v1/auth/external-callback` | Xử lý OAuth callback |
| `GET` | `/api/v1/auth/linked-accounts` | Lấy danh sách OAuth providers đã liên kết (cần auth) |
### Quản Lý User
| Method | Endpoint | Mô tả |
@@ -197,6 +223,94 @@ client.DefaultRequestHeaders.Authorization =
var user = await client.GetFromJsonAsync<UserDto>("/api/v1/users/me");
```
## Xác Minh Email
### Gửi Email Xác Minh
```bash
curl -X POST http://localhost:5001/api/v1/auth/send-verification-email \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
```
### Xác Nhận Email
```bash
curl -X POST http://localhost:5001/api/v1/auth/confirm-email \
-H "Content-Type: application/json" \
-d '{"userId": "user-guid", "token": "confirmation-token"}'
```
## Xác Thực Hai Yếu Tố (2FA)
### Bật 2FA
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/enable \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
**Response:**
```json
{
"success": true,
"data": {
"secretKey": "JBSWY3DPEHPK3PXP",
"qrCodeBase64": "data:image/png;base64,...",
"recoveryCodes": ["code1", "code2", "code3"]
}
}
```
### Xác Minh Mã 2FA
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/verify \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
```
### Tắt 2FA
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/disable \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
```
## Đăng Nhập Mạng Xã Hội
### Khởi Tạo OAuth Flow
Chuyển hướng user đến:
```
GET http://localhost:5001/api/v1/auth/external-login/Google?returnUrl=http://your-app/callback
GET http://localhost:5001/api/v1/auth/external-login/Facebook?returnUrl=http://your-app/callback
```
### Lấy Danh Sách Tài Khoản Liên Kết
```bash
curl http://localhost:5001/api/v1/auth/linked-accounts \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
**Response:**
```json
{
"success": true,
"data": {
"linkedProviders": [
{"provider": "Google", "providerDisplayName": "Google"},
{"provider": "Facebook", "providerDisplayName": "Facebook"}
]
}
}
```
## Xử Lý Lỗi
### Các Lỗi Thường Gặp

View File

@@ -10,6 +10,7 @@ graph TB
WEB[Web App]
MOB[Mobile App]
SVC[Other Services]
SOCIAL[Google/Facebook]
end
subgraph "API Layer"
@@ -35,16 +36,21 @@ graph TB
CTX[Identity DbContext]
REPO[Repositories]
IDSERVER[Duende IdentityServer]
EMAIL[Email Service]
TOTP[2FA Service]
OAUTH[Social Login Service]
end
subgraph "External"
DB[(PostgreSQL)]
REDIS[(Redis)]
SMTP[SMTP Server]
end
WEB --> AUTH
MOB --> AUTH
SVC --> TOK
SOCIAL --> AUTH
AUTH --> CMD
AUTH --> QRY
USR --> CMD
@@ -59,11 +65,16 @@ graph TB
IDSERVER --> CTX
CTX --> DB
CTX --> REDIS
EMAIL --> SMTP
OAUTH --> SOCIAL
style AUTH fill:#4a90d9,stroke:#2d5986,color:#fff
style USER fill:#50c878,stroke:#2d8659,color:#fff
style DB fill:#ff6b6b,stroke:#c0392b,color:#fff
style IDSERVER fill:#9b59b6,stroke:#7d3c98,color:#fff
style EMAIL fill:#e67e22,stroke:#d35400,color:#fff
style TOTP fill:#1abc9c,stroke:#16a085,color:#fff
style OAUTH fill:#3498db,stroke:#2980b9,color:#fff
```
## OAuth2 Authentication Flow
@@ -252,6 +263,8 @@ graph TD
JWT[JWT Bearer Tokens]
RS256[RS256 Signing]
OIDC[IdentityServer]
MFA[2FA/TOTP]
SOCIAL[Social OAuth]
end
subgraph "Authorization"
@@ -264,16 +277,104 @@ graph TD
HASH[bcrypt Password Hash]
HTTPS[HTTPS/TLS]
CORS[CORS Policy]
EMAIL[Email Verification]
end
JWT --> RS256
RS256 --> OIDC
RBAC --> CLAIMS
CLAIMS --> POLICY
MFA --> JWT
SOCIAL --> JWT
style JWT fill:#3498db,stroke:#2980b9,color:#fff
style RBAC fill:#e74c3c,stroke:#c0392b,color:#fff
style HASH fill:#2ecc71,stroke:#27ae60,color:#fff
style MFA fill:#9b59b6,stroke:#7d3c98,color:#fff
style SOCIAL fill:#e67e22,stroke:#d35400,color:#fff
```
## Email Verification Flow
```mermaid
sequenceDiagram
participant User
participant AuthController
participant EmailService
participant SMTP
participant Database
Note over User,Database: Send Verification Email
User->>AuthController: POST /send-verification-email
AuthController->>Database: Generate Token
Database-->>AuthController: Confirmation Token
AuthController->>EmailService: SendVerificationEmail()
EmailService->>SMTP: Send Email with Link
SMTP-->>User: Email with Verification Link
Note over User,Database: Confirm Email
User->>AuthController: POST /confirm-email<br/>(userId, token)
AuthController->>Database: Validate Token
Database-->>AuthController: Token Valid
AuthController->>Database: Set EmailConfirmed = true
AuthController-->>User: Email Confirmed
```
## Two-Factor Authentication Flow
```mermaid
sequenceDiagram
participant User
participant AuthController
participant TwoFactorService
participant Database
participant AuthenticatorApp
Note over User,AuthenticatorApp: Enable 2FA
User->>AuthController: POST /2fa/enable
AuthController->>TwoFactorService: GenerateSecretKey()
TwoFactorService-->>AuthController: Secret Key
AuthController->>TwoFactorService: GenerateQrCode()
TwoFactorService-->>AuthController: QR Code (Base64)
AuthController-->>User: Secret + QR Code + Recovery Codes
User->>AuthenticatorApp: Scan QR Code
Note over User,AuthenticatorApp: Verify & Activate
User->>AuthController: POST /2fa/verify (code)
AuthController->>TwoFactorService: ValidateCode(secret, code)
TwoFactorService-->>AuthController: Valid
AuthController->>Database: Store Secret & Enable 2FA
AuthController-->>User: 2FA Enabled
```
## Social Login Flow
```mermaid
sequenceDiagram
participant User
participant Client
participant AuthController
participant OAuthProvider
participant SocialLoginService
participant Database
Note over User,Database: OAuth Flow
User->>Client: Click "Login with Google"
Client->>AuthController: GET /external-login/Google
AuthController->>OAuthProvider: Redirect to OAuth
OAuthProvider->>User: Login & Consent
User->>OAuthProvider: Approve
OAuthProvider->>AuthController: GET /external-callback (code)
AuthController->>SocialLoginService: ProcessExternalLogin()
SocialLoginService->>Database: Find/Create User
Database-->>SocialLoginService: User
SocialLoginService-->>AuthController: User + Tokens
AuthController-->>Client: Redirect with tokens
```
## Health Checks

View File

@@ -10,6 +10,9 @@ This service provides OAuth2/OpenID Connect authentication and authorization:
- **User Management** - Registration, profile, soft-delete
- **Role-Based Access Control** - User roles and permissions
- **Token Management** - Access (15 min), Refresh (7 days) tokens
- **Email Verification** - SMTP-based email confirmation
- **Two-Factor Authentication (2FA)** - TOTP with QR code setup
- **Social Login** - Google and Facebook OAuth integration
- **CQRS Pattern** - MediatR for Commands/Queries
- **Clean Architecture** - Domain, Infrastructure, API layers
@@ -98,6 +101,29 @@ dotnet ef database update \
| `POST` | `/api/v1/auth/change-password` | Change password | ✅ |
| `POST` | `/api/v1/auth/logout` | Logout (revoke tokens) | ✅ |
### Email Verification (`/api/v1/auth`)
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| `POST` | `/api/v1/auth/send-verification-email` | Send email verification link | ✅ |
| `POST` | `/api/v1/auth/confirm-email` | Confirm email with token | ❌ |
### Two-Factor Authentication (`/api/v1/auth/2fa`)
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| `POST` | `/api/v1/auth/2fa/enable` | Enable 2FA (get QR code) | ✅ |
| `POST` | `/api/v1/auth/2fa/verify` | Verify TOTP code & activate | ✅ |
| `POST` | `/api/v1/auth/2fa/disable` | Disable 2FA | ✅ |
### Social Login (`/api/v1/auth`)
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| `GET` | `/api/v1/auth/external-login/{provider}` | Initiate OAuth flow (Google/Facebook) | ❌ |
| `GET` | `/api/v1/auth/external-callback` | Handle OAuth callback | ❌ |
| `GET` | `/api/v1/auth/linked-accounts` | Get linked OAuth providers | ✅ |
### User Management (`/api/v1/users`)
| Method | Endpoint | Description | Auth |
@@ -199,6 +225,94 @@ curl -X POST http://localhost:5001/connect/token \
| `refresh_token` | Token renewal | No (uses refresh token) |
| `client_credentials` | Service-to-service | No |
## Email Verification
### Send Verification Email
```bash
curl -X POST http://localhost:5001/api/v1/auth/send-verification-email \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
```
### Confirm Email
```bash
curl -X POST http://localhost:5001/api/v1/auth/confirm-email \
-H "Content-Type: application/json" \
-d '{"userId": "user-guid", "token": "confirmation-token"}'
```
## Two-Factor Authentication (2FA)
### Enable 2FA
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/enable \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
**Response:**
```json
{
"success": true,
"data": {
"secretKey": "JBSWY3DPEHPK3PXP",
"qrCodeBase64": "data:image/png;base64,...",
"recoveryCodes": ["code1", "code2", "code3", ...]
}
}
```
### Verify 2FA Code
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/verify \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
```
### Disable 2FA
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/disable \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
```
## Social Login
### Initiate OAuth Flow
Redirect user to:
```
GET http://localhost:5001/api/v1/auth/external-login/Google?returnUrl=http://your-app/callback
GET http://localhost:5001/api/v1/auth/external-login/Facebook?returnUrl=http://your-app/callback
```
### Get Linked Accounts
```bash
curl http://localhost:5001/api/v1/auth/linked-accounts \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
**Response:**
```json
{
"success": true,
"data": {
"linkedProviders": [
{"provider": "Google", "providerDisplayName": "Google"},
{"provider": "Facebook", "providerDisplayName": "Facebook"}
]
}
}
```
## Configuration
### Environment Variables

View File

@@ -10,6 +10,7 @@ graph TB
WEB[Web App]
MOB[Mobile App]
SVC[Các Services khác]
SOCIAL[Google/Facebook]
end
subgraph "Lớp API"
@@ -35,16 +36,21 @@ graph TB
CTX[Identity DbContext]
REPO[Repositories]
IDSERVER[Duende IdentityServer]
EMAIL[Email Service]
TOTP[2FA Service]
OAUTH[Social Login Service]
end
subgraph "External"
DB[(PostgreSQL)]
REDIS[(Redis)]
SMTP[SMTP Server]
end
WEB --> AUTH
MOB --> AUTH
SVC --> TOK
SOCIAL --> AUTH
AUTH --> CMD
AUTH --> QRY
USR --> CMD
@@ -59,11 +65,16 @@ graph TB
IDSERVER --> CTX
CTX --> DB
CTX --> REDIS
EMAIL --> SMTP
OAUTH --> SOCIAL
style AUTH fill:#4a90d9,stroke:#2d5986,color:#fff
style USER fill:#50c878,stroke:#2d8659,color:#fff
style DB fill:#ff6b6b,stroke:#c0392b,color:#fff
style IDSERVER fill:#9b59b6,stroke:#7d3c98,color:#fff
style EMAIL fill:#e67e22,stroke:#d35400,color:#fff
style TOTP fill:#1abc9c,stroke:#16a085,color:#fff
style OAUTH fill:#3498db,stroke:#2980b9,color:#fff
```
## Luồng Xác Thực OAuth2
@@ -252,6 +263,8 @@ graph TD
JWT[JWT Bearer Tokens]
RS256[RS256 Signing]
OIDC[IdentityServer]
MFA[2FA/TOTP]
SOCIAL[Social OAuth]
end
subgraph "Authorization"
@@ -264,16 +277,104 @@ graph TD
HASH[bcrypt Password Hash]
HTTPS[HTTPS/TLS]
CORS[CORS Policy]
EMAIL[Email Verification]
end
JWT --> RS256
RS256 --> OIDC
RBAC --> CLAIMS
CLAIMS --> POLICY
MFA --> JWT
SOCIAL --> JWT
style JWT fill:#3498db,stroke:#2980b9,color:#fff
style RBAC fill:#e74c3c,stroke:#c0392b,color:#fff
style HASH fill:#2ecc71,stroke:#27ae60,color:#fff
style MFA fill:#9b59b6,stroke:#7d3c98,color:#fff
style SOCIAL fill:#e67e22,stroke:#d35400,color:#fff
```
## Luồng Xác Thực Email
```mermaid
sequenceDiagram
participant User
participant AuthController
participant EmailService
participant SMTP
participant Database
Note over User,Database: Gửi Email Xác Thực
User->>AuthController: POST /send-verification-email
AuthController->>Database: Tạo Token
Database-->>AuthController: Confirmation Token
AuthController->>EmailService: SendVerificationEmail()
EmailService->>SMTP: Gửi Email với Link
SMTP-->>User: Email với Link Xác Thực
Note over User,Database: Xác Nhận Email
User->>AuthController: POST /confirm-email<br/>(userId, token)
AuthController->>Database: Kiểm tra Token
Database-->>AuthController: Token hợp lệ
AuthController->>Database: Set EmailConfirmed = true
AuthController-->>User: Email đã xác nhận
```
## Luồng Xác Thực Hai Yếu Tố (2FA)
```mermaid
sequenceDiagram
participant User
participant AuthController
participant TwoFactorService
participant Database
participant AuthenticatorApp
Note over User,AuthenticatorApp: Bật 2FA
User->>AuthController: POST /2fa/enable
AuthController->>TwoFactorService: GenerateSecretKey()
TwoFactorService-->>AuthController: Secret Key
AuthController->>TwoFactorService: GenerateQrCode()
TwoFactorService-->>AuthController: QR Code (Base64)
AuthController-->>User: Secret + QR Code + Recovery Codes
User->>AuthenticatorApp: Quét QR Code
Note over User,AuthenticatorApp: Xác Minh & Kích Hoạt
User->>AuthController: POST /2fa/verify (code)
AuthController->>TwoFactorService: ValidateCode(secret, code)
TwoFactorService-->>AuthController: Hợp lệ
AuthController->>Database: Lưu Secret & Bật 2FA
AuthController-->>User: 2FA đã được bật
```
## Luồng Đăng Nhập Mạng Xã Hội
```mermaid
sequenceDiagram
participant User
participant Client
participant AuthController
participant OAuthProvider
participant SocialLoginService
participant Database
Note over User,Database: OAuth Flow
User->>Client: Click "Đăng nhập với Google"
Client->>AuthController: GET /external-login/Google
AuthController->>OAuthProvider: Redirect đến OAuth
OAuthProvider->>User: Login & Đồng ý
User->>OAuthProvider: Chấp thuận
OAuthProvider->>AuthController: GET /external-callback (code)
AuthController->>SocialLoginService: ProcessExternalLogin()
SocialLoginService->>Database: Tìm/Tạo User
Database-->>SocialLoginService: User
SocialLoginService-->>AuthController: User + Tokens
AuthController-->>Client: Redirect với tokens
```
## Health Checks

View File

@@ -10,6 +10,9 @@ IAM Service cung cấp các chức năng quản lý danh tính và truy cập:
- **User Management** - CRUD operations cho users
- **Password Management** - Đổi mật khẩu
- **Token Management** - Issue, refresh, revoke tokens
- **Email Verification** - Xác thực email qua SMTP
- **2FA/MFA** - Xác thực hai yếu tố với TOTP
- **Social Login** - Đăng nhập qua Google và Facebook
- **CQRS Pattern** - MediatR cho Commands/Queries
- **Clean Architecture** - Domain, Infrastructure, API layers
@@ -111,6 +114,29 @@ dotnet ef migrations list \
| `POST` | `/api/v1/auth/change-password` | Đổi mật khẩu | ✅ |
| `POST` | `/api/v1/auth/logout` | Đăng xuất (revoke tokens) | ✅ |
### Email Verification (`/api/v1/auth`)
| Method | Endpoint | Mô Tả | Auth |
|--------|----------|-------|------|
| `POST` | `/api/v1/auth/send-verification-email` | Gửi email xác thực | ✅ |
| `POST` | `/api/v1/auth/confirm-email` | Xác nhận email với token | ❌ |
### Xác Thực Hai Yếu Tố (`/api/v1/auth/2fa`)
| Method | Endpoint | Mô Tả | Auth |
|--------|----------|-------|------|
| `POST` | `/api/v1/auth/2fa/enable` | Bật 2FA (lấy QR code) | ✅ |
| `POST` | `/api/v1/auth/2fa/verify` | Xác minh mã TOTP & kích hoạt | ✅ |
| `POST` | `/api/v1/auth/2fa/disable` | Tắt 2FA | ✅ |
### Đăng Nhập Mạng Xã Hội (`/api/v1/auth`)
| Method | Endpoint | Mô Tả | Auth |
|--------|----------|-------|------|
| `GET` | `/api/v1/auth/external-login/{provider}` | Bắt đầu OAuth flow (Google/Facebook) | ❌ |
| `GET` | `/api/v1/auth/external-callback` | Xử lý OAuth callback | ❌ |
| `GET` | `/api/v1/auth/linked-accounts` | Lấy danh sách providers đã liên kết | ✅ |
### User Management (`/api/v1/users`)
| Method | Endpoint | Mô Tả | Auth |
@@ -215,6 +241,94 @@ curl -X POST http://localhost:5001/connect/token \
-d "scope=api"
```
## Xác Thực Email
### Gửi Email Xác Thực
```bash
curl -X POST http://localhost:5001/api/v1/auth/send-verification-email \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
```
### Xác Nhận Email
```bash
curl -X POST http://localhost:5001/api/v1/auth/confirm-email \
-H "Content-Type: application/json" \
-d '{"userId": "user-guid", "token": "confirmation-token"}'
```
## Xác Thực Hai Yếu Tố (2FA)
### Bật 2FA
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/enable \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
**Response:**
```json
{
"success": true,
"data": {
"secretKey": "JBSWY3DPEHPK3PXP",
"qrCodeBase64": "data:image/png;base64,...",
"recoveryCodes": ["code1", "code2", "code3", ...]
}
}
```
### Xác Minh Mã 2FA
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/verify \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
```
### Tắt 2FA
```bash
curl -X POST http://localhost:5001/api/v1/auth/2fa/disable \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
```
## Đăng Nhập Mạng Xã Hội
### Bắt Đầu OAuth Flow
Chuyển hướng user tới:
```
GET http://localhost:5001/api/v1/auth/external-login/Google?returnUrl=http://your-app/callback
GET http://localhost:5001/api/v1/auth/external-login/Facebook?returnUrl=http://your-app/callback
```
### Lấy Tài Khoản Đã Liên Kết
```bash
curl http://localhost:5001/api/v1/auth/linked-accounts \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
**Response:**
```json
{
"success": true,
"data": {
"linkedProviders": [
{"provider": "Google", "providerDisplayName": "Google"},
{"provider": "Facebook", "providerDisplayName": "Facebook"}
]
}
}
```
## Swagger UI
Sau khi chạy service, truy cập Swagger UI tại:

View File

@@ -0,0 +1,24 @@
// EN: Command to confirm email
// VI: Command để xác nhận email
using MediatR;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Command to confirm email with token.
/// VI: Command để xác nhận email với token.
/// </summary>
public record ConfirmEmailCommand(
string Email,
string Token
) : IRequest<ConfirmEmailResult>;
/// <summary>
/// EN: Result of email confirmation.
/// VI: Kết quả xác nhận email.
/// </summary>
public record ConfirmEmailResult(
bool Success,
string Message
);

View File

@@ -0,0 +1,66 @@
// EN: Handler for ConfirmEmailCommand
// VI: Handler cho ConfirmEmailCommand
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using IamService.Domain.AggregatesModel.UserAggregate;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Handler for ConfirmEmailCommand.
/// VI: Handler cho ConfirmEmailCommand.
/// </summary>
public class ConfirmEmailCommandHandler : IRequestHandler<ConfirmEmailCommand, ConfirmEmailResult>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<ConfirmEmailCommandHandler> _logger;
public ConfirmEmailCommandHandler(
UserManager<ApplicationUser> userManager,
ILogger<ConfirmEmailCommandHandler> logger)
{
_userManager = userManager;
_logger = logger;
}
public async Task<ConfirmEmailResult> Handle(
ConfirmEmailCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Confirming email for {Email}", request.Email);
// EN: Find user by email
// VI: Tìm user theo email
var user = await _userManager.FindByEmailAsync(request.Email);
if (user == null)
{
_logger.LogWarning("User not found with email {Email}", request.Email);
return new ConfirmEmailResult(false, "Invalid email or token.");
}
// EN: Check if email is already confirmed
// VI: Kiểm tra xem email đã được xác nhận chưa
if (user.EmailConfirmed)
{
_logger.LogInformation("Email {Email} is already confirmed", request.Email);
return new ConfirmEmailResult(true, "Email is already verified.");
}
// EN: Confirm email with token
// VI: Xác nhận email với token
var result = await _userManager.ConfirmEmailAsync(user, request.Token);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
_logger.LogWarning("Failed to confirm email for {Email}: {Errors}", request.Email, errors);
return new ConfirmEmailResult(false, "Invalid or expired token.");
}
_logger.LogInformation("Email confirmed successfully for {Email}", request.Email);
return new ConfirmEmailResult(true, "Email confirmed successfully.");
}
}

View File

@@ -0,0 +1,24 @@
// EN: Command to disable 2FA
// VI: Command để tắt 2FA
using MediatR;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Command to disable 2FA for user.
/// VI: Command để tắt 2FA cho user.
/// </summary>
public record Disable2FACommand(
Guid UserId,
string Code
) : IRequest<Disable2FAResult>;
/// <summary>
/// EN: Result of disabling 2FA.
/// VI: Kết quả tắt 2FA.
/// </summary>
public record Disable2FAResult(
bool Success,
string Message
);

View File

@@ -0,0 +1,98 @@
// EN: Handler for Disable2FACommand
// VI: Handler cho Disable2FACommand
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Infrastructure.TwoFactor;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Handler for Disable2FACommand.
/// VI: Handler cho Disable2FACommand.
/// </summary>
public class Disable2FACommandHandler : IRequestHandler<Disable2FACommand, Disable2FAResult>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITwoFactorService _twoFactorService;
private readonly ILogger<Disable2FACommandHandler> _logger;
public Disable2FACommandHandler(
UserManager<ApplicationUser> userManager,
ITwoFactorService twoFactorService,
ILogger<Disable2FACommandHandler> logger)
{
_userManager = userManager;
_twoFactorService = twoFactorService;
_logger = logger;
}
public async Task<Disable2FAResult> Handle(
Disable2FACommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Disabling 2FA for user {UserId}", request.UserId);
// EN: Find user by ID
// VI: Tìm user theo ID
var user = await _userManager.FindByIdAsync(request.UserId.ToString());
if (user == null)
{
_logger.LogWarning("User not found with ID {UserId}", request.UserId);
return new Disable2FAResult(false, "User not found.");
}
// EN: Check if 2FA is enabled
// VI: Kiểm tra xem 2FA có được bật không
if (!await _userManager.GetTwoFactorEnabledAsync(user))
{
_logger.LogInformation("2FA is not enabled for user {UserId}", request.UserId);
return new Disable2FAResult(false, "Two-factor authentication is not enabled.");
}
// EN: Get secret key and validate code
// VI: Lấy secret key và xác minh mã
var secretKey = await _userManager.GetAuthenticationTokenAsync(
user,
"[TwoFactor]",
"SecretKey");
if (string.IsNullOrEmpty(secretKey))
{
_logger.LogWarning("No 2FA secret key found for user {UserId}", request.UserId);
return new Disable2FAResult(false, "2FA configuration not found.");
}
// EN: Validate the code before disabling
// VI: Xác minh mã trước khi tắt
var isValid = _twoFactorService.ValidateCode(secretKey, request.Code);
if (!isValid)
{
_logger.LogWarning("Invalid 2FA code for user {UserId}", request.UserId);
return new Disable2FAResult(false, "Invalid verification code.");
}
// EN: Disable 2FA for user
// VI: Tắt 2FA cho user
var result = await _userManager.SetTwoFactorEnabledAsync(user, false);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
_logger.LogWarning("Failed to disable 2FA for user {UserId}: {Errors}", request.UserId, errors);
return new Disable2FAResult(false, $"Failed to disable 2FA: {errors}");
}
// EN: Remove secret key and recovery codes
// VI: Xóa secret key và mã khôi phục
await _userManager.RemoveAuthenticationTokenAsync(user, "[TwoFactor]", "SecretKey");
await _userManager.RemoveAuthenticationTokenAsync(user, "[TwoFactor]", "RecoveryCodes");
_logger.LogInformation("2FA disabled successfully for user {UserId}", request.UserId);
return new Disable2FAResult(true, "Two-factor authentication disabled successfully.");
}
}

View File

@@ -0,0 +1,26 @@
// EN: Command to enable 2FA
// VI: Command để bật 2FA
using MediatR;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Command to enable 2FA for user.
/// VI: Command để bật 2FA cho user.
/// </summary>
public record Enable2FACommand(
Guid UserId
) : IRequest<Enable2FAResult>;
/// <summary>
/// EN: Result of enabling 2FA.
/// VI: Kết quả bật 2FA.
/// </summary>
public record Enable2FAResult(
bool Success,
string SecretKey,
string QrCodeBase64,
string ManualEntryKey,
string[] RecoveryCodes
);

View File

@@ -0,0 +1,92 @@
// EN: Handler for Enable2FACommand
// VI: Handler cho Enable2FACommand
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Infrastructure.TwoFactor;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Handler for Enable2FACommand.
/// VI: Handler cho Enable2FACommand.
/// </summary>
public class Enable2FACommandHandler : IRequestHandler<Enable2FACommand, Enable2FAResult>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITwoFactorService _twoFactorService;
private readonly ILogger<Enable2FACommandHandler> _logger;
public Enable2FACommandHandler(
UserManager<ApplicationUser> userManager,
ITwoFactorService twoFactorService,
ILogger<Enable2FACommandHandler> logger)
{
_userManager = userManager;
_twoFactorService = twoFactorService;
_logger = logger;
}
public async Task<Enable2FAResult> Handle(
Enable2FACommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Enabling 2FA for user {UserId}", request.UserId);
// EN: Find user by ID
// VI: Tìm user theo ID
var user = await _userManager.FindByIdAsync(request.UserId.ToString());
if (user == null)
{
_logger.LogWarning("User not found with ID {UserId}", request.UserId);
throw new InvalidOperationException("User not found");
}
// EN: Check if 2FA is already enabled
// VI: Kiểm tra xem 2FA đã được bật chưa
if (await _userManager.GetTwoFactorEnabledAsync(user))
{
_logger.LogInformation("2FA is already enabled for user {UserId}", request.UserId);
throw new InvalidOperationException("Two-factor authentication is already enabled.");
}
// EN: Generate new secret key
// VI: Tạo secret key mới
var secretKey = _twoFactorService.GenerateSecretKey();
// EN: Store the key temporarily (will be confirmed after verification)
// VI: Lưu key tạm thời (sẽ được xác nhận sau khi xác minh)
await _userManager.SetAuthenticationTokenAsync(
user,
"[TwoFactor]",
"PendingSecretKey",
secretKey);
// EN: Generate QR code
// VI: Tạo QR code
var qrCodeBase64 = _twoFactorService.GenerateQrCodeBase64(user.Email!, secretKey);
// EN: Generate recovery codes
// VI: Tạo mã khôi phục
var recoveryCodes = _twoFactorService.GenerateRecoveryCodes(10).ToArray();
// EN: Store recovery codes (hashed)
// VI: Lưu mã khôi phục (đã hash)
await _userManager.SetAuthenticationTokenAsync(
user,
"[TwoFactor]",
"RecoveryCodes",
string.Join(";", recoveryCodes));
_logger.LogInformation("2FA setup initiated for user {UserId}", request.UserId);
return new Enable2FAResult(
Success: true,
SecretKey: secretKey,
QrCodeBase64: qrCodeBase64,
ManualEntryKey: secretKey,
RecoveryCodes: recoveryCodes);
}
}

View File

@@ -0,0 +1,70 @@
// EN: Command to handle external login callback
// VI: Command xử lý callback external login
using MediatR;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Command to process external login from OAuth providers.
/// VI: Command xử lý external login từ OAuth providers.
/// </summary>
/// <param name="Provider">OAuth provider name (Google, Facebook)</param>
/// <param name="ProviderUserId">User ID from the provider</param>
/// <param name="Email">User email from provider</param>
/// <param name="Name">User name from provider</param>
/// <param name="PictureUrl">Profile picture URL</param>
public record ExternalLoginCommand(
string Provider,
string ProviderUserId,
string Email,
string? Name,
string? PictureUrl) : IRequest<ExternalLoginResult>;
/// <summary>
/// EN: Result of external login command.
/// VI: Kết quả của command external login.
/// </summary>
public record ExternalLoginResult
{
/// <summary>
/// EN: Whether login was successful.
/// VI: Đăng nhập có thành công không.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// EN: Error message if login failed.
/// VI: Thông báo lỗi nếu đăng nhập thất bại.
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>
/// EN: User ID if successful.
/// VI: User ID nếu thành công.
/// </summary>
public Guid? UserId { get; init; }
/// <summary>
/// EN: User email.
/// VI: Email của user.
/// </summary>
public string? Email { get; init; }
/// <summary>
/// EN: Whether this is a new user registration.
/// VI: Đây có phải đăng ký user mới không.
/// </summary>
public bool IsNewUser { get; init; }
/// <summary>
/// EN: Access token for the user.
/// VI: Access token cho user.
/// </summary>
public string? AccessToken { get; init; }
/// <summary>
/// EN: Refresh token for the user.
/// VI: Refresh token cho user.
/// </summary>
public string? RefreshToken { get; init; }
}

View File

@@ -0,0 +1,97 @@
// EN: Handler for external login command
// VI: Handler cho command external login
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Infrastructure.SocialLogin;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Handler for processing external login from OAuth providers.
/// VI: Handler xử lý external login từ OAuth providers.
/// </summary>
public class ExternalLoginCommandHandler : IRequestHandler<ExternalLoginCommand, ExternalLoginResult>
{
private readonly ISocialLoginService _socialLoginService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ILogger<ExternalLoginCommandHandler> _logger;
public ExternalLoginCommandHandler(
ISocialLoginService socialLoginService,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ILogger<ExternalLoginCommandHandler> logger)
{
_socialLoginService = socialLoginService;
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
public async Task<ExternalLoginResult> Handle(ExternalLoginCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"EN: Processing external login for {Provider} / VI: Xử lý external login cho {Provider}",
request.Provider);
try
{
// EN: Process external login
// VI: Xử lý external login
var result = await _socialLoginService.ProcessExternalLoginAsync(
request.Provider,
request.ProviderUserId,
request.Email,
request.Name,
request.PictureUrl);
if (!result.Success || result.User == null)
{
_logger.LogWarning(
"EN: External login failed: {Error} / VI: External login thất bại: {Error}",
result.ErrorMessage);
return new ExternalLoginResult
{
Success = false,
Message = result.ErrorMessage ?? "External login failed"
};
}
// EN: Sign in the user
// VI: Đăng nhập user
await _signInManager.SignInAsync(result.User, isPersistent: false);
_logger.LogInformation(
"EN: External login successful for user {UserId} / VI: External login thành công cho user {UserId}",
result.User.Id);
// EN: Note: Token generation should be handled by the controller
// VI: Lưu ý: Tạo token nên được xử lý bởi controller
return new ExternalLoginResult
{
Success = true,
Message = result.IsNewUser
? "Account created and logged in successfully"
: "Logged in successfully",
UserId = result.User.Id,
Email = result.User.Email,
IsNewUser = result.IsNewUser
};
}
catch (Exception ex)
{
_logger.LogError(ex,
"EN: Error during external login / VI: Lỗi trong quá trình external login");
return new ExternalLoginResult
{
Success = false,
Message = "An error occurred during external authentication"
};
}
}
}

View File

@@ -0,0 +1,23 @@
// EN: Command to send verification email
// VI: Command để gửi email xác thực
using MediatR;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Command to send email verification link.
/// VI: Command để gửi link xác thực email.
/// </summary>
public record SendVerificationEmailCommand(
string Email
) : IRequest<SendVerificationEmailResult>;
/// <summary>
/// EN: Result of sending verification email.
/// VI: Kết quả gửi email xác thực.
/// </summary>
public record SendVerificationEmailResult(
bool Success,
string Message
);

View File

@@ -0,0 +1,69 @@
// EN: Handler for SendVerificationEmailCommand
// VI: Handler cho SendVerificationEmailCommand
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Infrastructure.Email;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Handler for SendVerificationEmailCommand.
/// VI: Handler cho SendVerificationEmailCommand.
/// </summary>
public class SendVerificationEmailCommandHandler : IRequestHandler<SendVerificationEmailCommand, SendVerificationEmailResult>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly IEmailService _emailService;
private readonly ILogger<SendVerificationEmailCommandHandler> _logger;
public SendVerificationEmailCommandHandler(
UserManager<ApplicationUser> userManager,
IEmailService emailService,
ILogger<SendVerificationEmailCommandHandler> logger)
{
_userManager = userManager;
_emailService = emailService;
_logger = logger;
}
public async Task<SendVerificationEmailResult> Handle(
SendVerificationEmailCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Sending verification email to {Email}", request.Email);
// EN: Find user by email
// VI: Tìm user theo email
var user = await _userManager.FindByEmailAsync(request.Email);
if (user == null)
{
_logger.LogWarning("User not found with email {Email}", request.Email);
// EN: Return success to prevent email enumeration attacks
// VI: Trả về success để ngăn tấn công liệt kê email
return new SendVerificationEmailResult(true, "If the email exists, a verification link has been sent.");
}
// EN: Check if email is already confirmed
// VI: Kiểm tra xem email đã được xác nhận chưa
if (user.EmailConfirmed)
{
_logger.LogInformation("Email {Email} is already confirmed", request.Email);
return new SendVerificationEmailResult(true, "Email is already verified.");
}
// EN: Generate email confirmation token
// VI: Tạo token xác nhận email
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
// EN: Send verification email
// VI: Gửi email xác thực
await _emailService.SendVerificationEmailAsync(request.Email, token, cancellationToken);
_logger.LogInformation("Verification email sent to {Email}", request.Email);
return new SendVerificationEmailResult(true, "Verification email sent successfully.");
}
}

View File

@@ -0,0 +1,24 @@
// EN: Command to verify 2FA code and complete setup
// VI: Command để xác minh mã 2FA và hoàn tất cài đặt
using MediatR;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Command to verify 2FA code and complete 2FA setup.
/// VI: Command để xác minh mã 2FA và hoàn tất cài đặt 2FA.
/// </summary>
public record Verify2FACommand(
Guid UserId,
string Code
) : IRequest<Verify2FAResult>;
/// <summary>
/// EN: Result of 2FA verification.
/// VI: Kết quả xác minh 2FA.
/// </summary>
public record Verify2FAResult(
bool Success,
string Message
);

View File

@@ -0,0 +1,100 @@
// EN: Handler for Verify2FACommand
// VI: Handler cho Verify2FACommand
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Infrastructure.TwoFactor;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Handler for Verify2FACommand.
/// VI: Handler cho Verify2FACommand.
/// </summary>
public class Verify2FACommandHandler : IRequestHandler<Verify2FACommand, Verify2FAResult>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITwoFactorService _twoFactorService;
private readonly ILogger<Verify2FACommandHandler> _logger;
public Verify2FACommandHandler(
UserManager<ApplicationUser> userManager,
ITwoFactorService twoFactorService,
ILogger<Verify2FACommandHandler> logger)
{
_userManager = userManager;
_twoFactorService = twoFactorService;
_logger = logger;
}
public async Task<Verify2FAResult> Handle(
Verify2FACommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation("Verifying 2FA code for user {UserId}", request.UserId);
// EN: Find user by ID
// VI: Tìm user theo ID
var user = await _userManager.FindByIdAsync(request.UserId.ToString());
if (user == null)
{
_logger.LogWarning("User not found with ID {UserId}", request.UserId);
return new Verify2FAResult(false, "User not found.");
}
// EN: Get pending secret key
// VI: Lấy secret key đang chờ
var pendingSecretKey = await _userManager.GetAuthenticationTokenAsync(
user,
"[TwoFactor]",
"PendingSecretKey");
if (string.IsNullOrEmpty(pendingSecretKey))
{
_logger.LogWarning("No pending 2FA setup found for user {UserId}", request.UserId);
return new Verify2FAResult(false, "No pending 2FA setup found. Please enable 2FA first.");
}
// EN: Validate the code
// VI: Xác minh mã
var isValid = _twoFactorService.ValidateCode(pendingSecretKey, request.Code);
if (!isValid)
{
_logger.LogWarning("Invalid 2FA code for user {UserId}", request.UserId);
return new Verify2FAResult(false, "Invalid verification code.");
}
// EN: Enable 2FA for user
// VI: Bật 2FA cho user
var result = await _userManager.SetTwoFactorEnabledAsync(user, true);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
_logger.LogWarning("Failed to enable 2FA for user {UserId}: {Errors}", request.UserId, errors);
return new Verify2FAResult(false, $"Failed to enable 2FA: {errors}");
}
// EN: Store the confirmed secret key
// VI: Lưu secret key đã xác nhận
await _userManager.SetAuthenticationTokenAsync(
user,
"[TwoFactor]",
"SecretKey",
pendingSecretKey);
// EN: Remove pending secret key
// VI: Xóa secret key đang chờ
await _userManager.RemoveAuthenticationTokenAsync(
user,
"[TwoFactor]",
"PendingSecretKey");
_logger.LogInformation("2FA enabled successfully for user {UserId}", request.UserId);
return new Verify2FAResult(true, "Two-factor authentication enabled successfully.");
}
}

View File

@@ -217,6 +217,332 @@ public class AuthController : ControllerBase
return Ok(new LogoutResponse { Success = result.Success, Message = result.Message });
}
#region Email Verification Endpoints
/// <summary>
/// EN: Send email verification link.
/// VI: Gửi link xác thực email.
/// </summary>
/// <param name="request">Email address to verify</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of sending verification email</returns>
[HttpPost("send-verification-email")]
[SwaggerOperation(
Summary = "Send verification email",
Description = "Sends an email verification link to the specified email address.",
OperationId = "SendVerificationEmail")]
[SwaggerResponse(StatusCodes.Status200OK, "Verification email sent")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid email")]
[ProducesResponseType(typeof(ApiResponse<SendVerificationEmailResult>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SendVerificationEmail(
[FromBody, SwaggerRequestBody("Email address", Required = true)] SendVerificationEmailRequest request,
CancellationToken cancellationToken)
{
var command = new SendVerificationEmailCommand(request.Email);
var result = await _mediator.Send(command, cancellationToken);
return Ok(ApiResponse<SendVerificationEmailResult>.Ok(result));
}
/// <summary>
/// EN: Confirm email with token.
/// VI: Xác nhận email với token.
/// </summary>
/// <param name="request">Email and token</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of email confirmation</returns>
[HttpPost("confirm-email")]
[SwaggerOperation(
Summary = "Confirm email",
Description = "Confirms user email with the provided token.",
OperationId = "ConfirmEmail")]
[SwaggerResponse(StatusCodes.Status200OK, "Email confirmed successfully")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid or expired token")]
[ProducesResponseType(typeof(ApiResponse<ConfirmEmailResult>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ConfirmEmail(
[FromBody, SwaggerRequestBody("Email and token", Required = true)] ConfirmEmailRequest request,
CancellationToken cancellationToken)
{
var command = new ConfirmEmailCommand(request.Email, request.Token);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
{
return BadRequest(ApiResponse<ConfirmEmailResult>.Fail("INVALID_TOKEN", result.Message));
}
return Ok(ApiResponse<ConfirmEmailResult>.Ok(result));
}
#endregion
#region Two-Factor Authentication Endpoints
/// <summary>
/// EN: Enable 2FA for current user.
/// VI: Bật 2FA cho user hiện tại.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>QR code and recovery codes</returns>
[HttpPost("2fa/enable")]
[Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[SwaggerOperation(
Summary = "Enable 2FA",
Description = "Initiates 2FA setup. Returns QR code and recovery codes. Must be verified with /2fa/verify.",
OperationId = "Enable2FA")]
[SwaggerResponse(StatusCodes.Status200OK, "2FA setup initiated", typeof(Enable2FAResponse))]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "2FA already enabled")]
[ProducesResponseType(typeof(ApiResponse<Enable2FAResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Enable2FA(CancellationToken cancellationToken)
{
var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
return Unauthorized();
}
try
{
var command = new Enable2FACommand(userId);
var result = await _mediator.Send(command, cancellationToken);
return Ok(ApiResponse<Enable2FAResponse>.Ok(new Enable2FAResponse
{
QrCodeBase64 = result.QrCodeBase64,
ManualEntryKey = result.ManualEntryKey,
RecoveryCodes = result.RecoveryCodes
}));
}
catch (InvalidOperationException ex)
{
return BadRequest(ApiResponse<Enable2FAResponse>.Fail("2FA_ALREADY_ENABLED", ex.Message));
}
}
/// <summary>
/// EN: Verify 2FA code and complete setup.
/// VI: Xác minh mã 2FA và hoàn tất cài đặt.
/// </summary>
/// <param name="request">2FA verification code</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of 2FA verification</returns>
[HttpPost("2fa/verify")]
[Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[SwaggerOperation(
Summary = "Verify 2FA code",
Description = "Verifies the TOTP code and completes 2FA setup.",
OperationId = "Verify2FA")]
[SwaggerResponse(StatusCodes.Status200OK, "2FA enabled successfully")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid code")]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[ProducesResponseType(typeof(ApiResponse<Verify2FAResult>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Verify2FA(
[FromBody, SwaggerRequestBody("2FA code", Required = true)] Verify2FARequest request,
CancellationToken cancellationToken)
{
var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
return Unauthorized();
}
var command = new Verify2FACommand(userId, request.Code);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
{
return BadRequest(ApiResponse<Verify2FAResult>.Fail("INVALID_CODE", result.Message));
}
return Ok(ApiResponse<Verify2FAResult>.Ok(result));
}
/// <summary>
/// EN: Disable 2FA for current user.
/// VI: Tắt 2FA cho user hiện tại.
/// </summary>
/// <param name="request">Current 2FA code for verification</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of disabling 2FA</returns>
[HttpPost("2fa/disable")]
[Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[SwaggerOperation(
Summary = "Disable 2FA",
Description = "Disables 2FA for the current user. Requires verification with current 2FA code.",
OperationId = "Disable2FA")]
[SwaggerResponse(StatusCodes.Status200OK, "2FA disabled successfully")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid code or 2FA not enabled")]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[ProducesResponseType(typeof(ApiResponse<Disable2FAResult>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Disable2FA(
[FromBody, SwaggerRequestBody("2FA code", Required = true)] Disable2FARequest request,
CancellationToken cancellationToken)
{
var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
return Unauthorized();
}
var command = new Disable2FACommand(userId, request.Code);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
{
return BadRequest(ApiResponse<Disable2FAResult>.Fail("DISABLE_FAILED", result.Message));
}
return Ok(ApiResponse<Disable2FAResult>.Ok(result));
}
#endregion
#region Social Login Endpoints
/// <summary>
/// EN: Initiate external login with Google or Facebook.
/// VI: Bắt đầu đăng nhập bên ngoài với Google hoặc Facebook.
/// </summary>
/// <param name="provider">Provider name (Google, Facebook)</param>
/// <param name="returnUrl">URL to redirect after login</param>
/// <returns>Challenge result for external provider</returns>
[HttpGet("external-login/{provider}")]
[SwaggerOperation(
Summary = "Initiate external login",
Description = "Redirects user to external OAuth provider (Google or Facebook) for authentication.",
OperationId = "ExternalLogin")]
[SwaggerResponse(StatusCodes.Status302Found, "Redirecting to external provider")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid provider")]
public IActionResult ExternalLogin(
[FromRoute] string provider,
[FromQuery] string? returnUrl = null)
{
var validProviders = new[] { "Google", "Facebook" };
if (!validProviders.Contains(provider, StringComparer.OrdinalIgnoreCase))
{
return BadRequest(ApiResponse<object>.Fail("INVALID_PROVIDER", $"Provider '{provider}' is not supported. Valid providers: Google, Facebook"));
}
var redirectUrl = Url.Action(nameof(ExternalLoginCallback), "Auth", new { returnUrl });
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return Challenge(properties, provider);
}
/// <summary>
/// EN: Handle callback from external OAuth provider.
/// VI: Xử lý callback từ OAuth provider bên ngoài.
/// </summary>
/// <param name="returnUrl">URL to redirect after login</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>External login result with tokens</returns>
[HttpGet("external-callback")]
[SwaggerOperation(
Summary = "External login callback",
Description = "Handles callback from Google/Facebook OAuth. Creates or links user account.",
OperationId = "ExternalLoginCallback")]
[SwaggerResponse(StatusCodes.Status200OK, "Login successful", typeof(ExternalLoginResult))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "External login failed")]
public async Task<IActionResult> ExternalLoginCallback(
[FromQuery] string? returnUrl = null,
CancellationToken cancellationToken = default)
{
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info == null)
{
_logger.LogWarning("EN: External login info is null / VI: External login info là null");
return BadRequest(ApiResponse<ExternalLoginResult>.Fail("EXTERNAL_LOGIN_FAILED", "Could not get external login information"));
}
// EN: Extract user information from claims
// VI: Trích xuất thông tin user từ claims
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
var name = info.Principal.FindFirstValue(ClaimTypes.Name);
var pictureUrl = info.Principal.FindFirstValue("picture");
if (string.IsNullOrEmpty(email))
{
return BadRequest(ApiResponse<ExternalLoginResult>.Fail("EMAIL_REQUIRED", "Email is required for external login"));
}
var command = new ExternalLoginCommand(
info.LoginProvider,
info.ProviderKey,
email,
name,
pictureUrl);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
{
return BadRequest(ApiResponse<ExternalLoginResult>.Fail("EXTERNAL_LOGIN_FAILED", result.Message));
}
// EN: If returnUrl is specified, redirect to it with user info as query params
// VI: Nếu có returnUrl, redirect tới đó với thông tin user dưới dạng query params
if (!string.IsNullOrEmpty(returnUrl))
{
var redirectUrl = $"{returnUrl}?userId={result.UserId}&email={result.Email}&isNewUser={result.IsNewUser}";
return Redirect(redirectUrl);
}
return Ok(ApiResponse<ExternalLoginResult>.Ok(result));
}
/// <summary>
/// EN: Get linked external accounts for current user.
/// VI: Lấy danh sách tài khoản bên ngoài đã liên kết của user hiện tại.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of linked providers</returns>
[HttpGet("linked-accounts")]
[Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[SwaggerOperation(
Summary = "Get linked accounts",
Description = "Returns list of external OAuth providers linked to current user's account.",
OperationId = "GetLinkedAccounts")]
[SwaggerResponse(StatusCodes.Status200OK, "Linked accounts retrieved")]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[ProducesResponseType(typeof(ApiResponse<LinkedAccountsResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetLinkedAccounts(CancellationToken cancellationToken)
{
var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
return Unauthorized();
}
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user == null)
{
return Unauthorized();
}
var logins = await _userManager.GetLoginsAsync(user);
var providers = logins.Select(l => new LinkedAccountInfo
{
Provider = l.LoginProvider,
ProviderDisplayName = l.ProviderDisplayName ?? l.LoginProvider
}).ToList();
return Ok(ApiResponse<LinkedAccountsResponse>.Ok(new LinkedAccountsResponse
{
LinkedProviders = providers
}));
}
#endregion
}
#region Request/Response Models
@@ -339,3 +665,134 @@ public class LogoutResponse
}
#endregion
#region Email Verification Request/Response Models
/// <summary>
/// EN: Request body for sending verification email.
/// VI: Request body để gửi email xác thực.
/// </summary>
public class SendVerificationEmailRequest
{
/// <summary>
/// EN: Email address to verify.
/// VI: Địa chỉ email cần xác thực.
/// </summary>
/// <example>user@example.com</example>
public string Email { get; set; } = string.Empty;
}
/// <summary>
/// EN: Request body for confirming email.
/// VI: Request body để xác nhận email.
/// </summary>
public class ConfirmEmailRequest
{
/// <summary>
/// EN: Email address.
/// VI: Địa chỉ email.
/// </summary>
/// <example>user@example.com</example>
public string Email { get; set; } = string.Empty;
/// <summary>
/// EN: Verification token from email.
/// VI: Token xác thực từ email.
/// </summary>
public string Token { get; set; } = string.Empty;
}
#endregion
#region 2FA Request/Response Models
/// <summary>
/// EN: Response for enabling 2FA.
/// VI: Response cho việc bật 2FA.
/// </summary>
public class Enable2FAResponse
{
/// <summary>
/// EN: QR code as base64 image.
/// VI: QR code dạng ảnh base64.
/// </summary>
public string QrCodeBase64 { get; set; } = string.Empty;
/// <summary>
/// EN: Manual entry key for authenticator apps.
/// VI: Key nhập thủ công cho ứng dụng authenticator.
/// </summary>
public string ManualEntryKey { get; set; } = string.Empty;
/// <summary>
/// EN: Recovery codes for account recovery.
/// VI: Mã khôi phục cho tài khoản.
/// </summary>
public string[] RecoveryCodes { get; set; } = Array.Empty<string>();
}
/// <summary>
/// EN: Request body for 2FA verification.
/// VI: Request body cho xác minh 2FA.
/// </summary>
public class Verify2FARequest
{
/// <summary>
/// EN: TOTP code from authenticator app.
/// VI: Mã TOTP từ ứng dụng authenticator.
/// </summary>
/// <example>123456</example>
public string Code { get; set; } = string.Empty;
}
/// <summary>
/// EN: Request body for disabling 2FA.
/// VI: Request body cho việc tắt 2FA.
/// </summary>
public class Disable2FARequest
{
/// <summary>
/// EN: Current TOTP code for verification.
/// VI: Mã TOTP hiện tại để xác minh.
/// </summary>
/// <example>123456</example>
public string Code { get; set; } = string.Empty;
}
#endregion
#region Social Login Request/Response Models
/// <summary>
/// EN: Response for linked accounts.
/// VI: Response cho các tài khoản đã liên kết.
/// </summary>
public class LinkedAccountsResponse
{
/// <summary>
/// EN: List of linked OAuth providers.
/// VI: Danh sách các OAuth providers đã liên kết.
/// </summary>
public List<LinkedAccountInfo> LinkedProviders { get; set; } = new();
}
/// <summary>
/// EN: Information about a linked account.
/// VI: Thông tin về tài khoản đã liên kết.
/// </summary>
public class LinkedAccountInfo
{
/// <summary>
/// EN: Provider name (Google, Facebook).
/// VI: Tên provider (Google, Facebook).
/// </summary>
public string Provider { get; set; } = string.Empty;
/// <summary>
/// EN: Display name of the provider.
/// VI: Tên hiển thị của provider.
/// </summary>
public string ProviderDisplayName { get; set; } = string.Empty;
}
#endregion

View File

@@ -47,5 +47,30 @@
"AccessTokenExpiryMinutes": 15,
"RefreshTokenExpiryDays": 7
},
"Email": {
"SmtpServer": "smtp.mailgun.org",
"SmtpPort": 587,
"SmtpLogin": "your-mailgun-smtp-login",
"SmtpPassword": "your-mailgun-smtp-password",
"SenderEmail": "noreply@yourdomain.com",
"SenderName": "IAM Service",
"BaseUrl": "http://localhost:5001"
},
"TwoFactor": {
"Issuer": "IAM Service",
"CodeLength": 6,
"ValidityPeriodSeconds": 30
},
"SocialLogin": {
"Google": {
"ClientId": "",
"ClientSecret": ""
},
"Facebook": {
"AppId": "",
"AppSecret": ""
},
"CallbackUrl": "/api/auth/external-callback"
},
"AllowedHosts": "*"
}

View File

@@ -8,8 +8,11 @@ using Microsoft.Extensions.DependencyInjection;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Domain.AggregatesModel.RoleAggregate;
using IamService.Domain.SeedWork;
using IamService.Infrastructure.Email;
using IamService.Infrastructure.IdentityServer;
using IamService.Infrastructure.Repositories;
using IamService.Infrastructure.SocialLogin;
using IamService.Infrastructure.TwoFactor;
namespace IamService.Infrastructure;
@@ -167,6 +170,46 @@ public static class DependencyInjection
services.AddSingleton<Caching.ICacheService, Caching.RedisCacheService>();
}
// EN: Configure Email service
// VI: Cấu hình Email service
services.Configure<EmailSettings>(configuration.GetSection(EmailSettings.SectionName));
services.AddScoped<IEmailService, SmtpEmailService>();
// EN: Configure 2FA service
// VI: Cấu hình 2FA service
services.Configure<TwoFactorSettings>(configuration.GetSection(TwoFactorSettings.SectionName));
services.AddScoped<ITwoFactorService, TotpTwoFactorService>();
// EN: Configure Social Login service
// VI: Cấu hình Social Login service
services.Configure<SocialLoginSettings>(configuration.GetSection("SocialLogin"));
services.AddScoped<ISocialLoginService, SocialLoginService>();
// EN: Configure Google and Facebook authentication
// VI: Cấu hình Google và Facebook authentication
var socialLoginSettings = new SocialLoginSettings();
configuration.GetSection("SocialLogin").Bind(socialLoginSettings);
if (!string.IsNullOrEmpty(socialLoginSettings.Google?.ClientId))
{
services.AddAuthentication()
.AddGoogle(options =>
{
options.ClientId = socialLoginSettings.Google.ClientId;
options.ClientSecret = socialLoginSettings.Google.ClientSecret;
});
}
if (!string.IsNullOrEmpty(socialLoginSettings.Facebook?.AppId))
{
services.AddAuthentication()
.AddFacebook(options =>
{
options.AppId = socialLoginSettings.Facebook.AppId;
options.AppSecret = socialLoginSettings.Facebook.AppSecret;
});
}
return services;
}
}

View File

@@ -0,0 +1,55 @@
// EN: Email settings configuration for SMTP
// VI: Cấu hình email settings cho SMTP
namespace IamService.Infrastructure.Email;
/// <summary>
/// EN: Email settings for SMTP configuration.
/// VI: Cấu hình SMTP cho email.
/// </summary>
public class EmailSettings
{
public const string SectionName = "Email";
/// <summary>
/// EN: SMTP server address (e.g., smtp.mailgun.org).
/// VI: Địa chỉ SMTP server.
/// </summary>
public string SmtpServer { get; set; } = string.Empty;
/// <summary>
/// EN: SMTP port (typically 587 for TLS).
/// VI: Port SMTP (thường là 587 cho TLS).
/// </summary>
public int SmtpPort { get; set; } = 587;
/// <summary>
/// EN: SMTP login username.
/// VI: Tên đăng nhập SMTP.
/// </summary>
public string SmtpLogin { get; set; } = string.Empty;
/// <summary>
/// EN: SMTP password.
/// VI: Mật khẩu SMTP.
/// </summary>
public string SmtpPassword { get; set; } = string.Empty;
/// <summary>
/// EN: Sender email address.
/// VI: Địa chỉ email người gửi.
/// </summary>
public string SenderEmail { get; set; } = string.Empty;
/// <summary>
/// EN: Sender display name.
/// VI: Tên hiển thị người gửi.
/// </summary>
public string SenderName { get; set; } = "IAM Service";
/// <summary>
/// EN: Base URL for verification links.
/// VI: URL cơ sở cho các link xác thực.
/// </summary>
public string BaseUrl { get; set; } = "http://localhost:5001";
}

View File

@@ -0,0 +1,35 @@
// EN: Email service interface for sending emails
// VI: Interface email service để gửi email
namespace IamService.Infrastructure.Email;
/// <summary>
/// EN: Email service interface for sending various types of emails.
/// VI: Interface email service để gửi các loại email khác nhau.
/// </summary>
public interface IEmailService
{
/// <summary>
/// EN: Send a generic email.
/// VI: Gửi email chung.
/// </summary>
Task SendEmailAsync(string to, string subject, string htmlBody, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Send email verification email with token.
/// VI: Gửi email xác thực với token.
/// </summary>
Task SendVerificationEmailAsync(string email, string token, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Send 2FA code via email.
/// VI: Gửi mã 2FA qua email.
/// </summary>
Task Send2FACodeAsync(string email, string code, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Send password reset email.
/// VI: Gửi email đặt lại mật khẩu.
/// </summary>
Task SendPasswordResetEmailAsync(string email, string token, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,201 @@
// EN: SMTP email service implementation using MailKit
// VI: Implementation email service sử dụng MailKit
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;
namespace IamService.Infrastructure.Email;
/// <summary>
/// EN: SMTP-based email service implementation using MailKit.
/// VI: Implementation email service dựa trên SMTP sử dụng MailKit.
/// </summary>
public class SmtpEmailService : IEmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<SmtpEmailService> _logger;
public SmtpEmailService(
IOptions<EmailSettings> settings,
ILogger<SmtpEmailService> logger)
{
_settings = settings.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task SendEmailAsync(string to, string subject, string htmlBody, CancellationToken cancellationToken = default)
{
try
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(_settings.SenderName, _settings.SenderEmail));
message.To.Add(MailboxAddress.Parse(to));
message.Subject = subject;
var bodyBuilder = new BodyBuilder
{
HtmlBody = htmlBody
};
message.Body = bodyBuilder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(
_settings.SmtpServer,
_settings.SmtpPort,
SecureSocketOptions.StartTls,
cancellationToken);
await client.AuthenticateAsync(
_settings.SmtpLogin,
_settings.SmtpPassword,
cancellationToken);
await client.SendAsync(message, cancellationToken);
await client.DisconnectAsync(true, cancellationToken);
_logger.LogInformation("Email sent successfully to {To}, Subject: {Subject}", to, subject);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {To}, Subject: {Subject}", to, subject);
throw;
}
}
/// <inheritdoc />
public async Task SendVerificationEmailAsync(string email, string token, CancellationToken cancellationToken = default)
{
var verificationUrl = $"{_settings.BaseUrl}/api/v1/auth/confirm-email?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}";
var htmlBody = $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<title>Email Verification</title>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #4a90d9; color: white; padding: 20px; text-align: center; }}
.content {{ padding: 30px; background: #f9f9f9; }}
.button {{ display: inline-block; padding: 12px 30px; background: #4a90d9; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }}
.footer {{ padding: 20px; text-align: center; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class=""container"">
<div class=""header"">
<h1>Email Verification</h1>
</div>
<div class=""content"">
<p>Hello,</p>
<p>Thank you for registering. Please click the button below to verify your email address:</p>
<p style=""text-align: center;"">
<a href=""{verificationUrl}"" class=""button"">Verify Email</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style=""word-break: break-all; font-size: 12px;"">{verificationUrl}</p>
<p>This link will expire in 24 hours.</p>
<p>If you did not create an account, please ignore this email.</p>
</div>
<div class=""footer"">
<p>&copy; {DateTime.UtcNow.Year} IAM Service. All rights reserved.</p>
</div>
</div>
</body>
</html>";
await SendEmailAsync(email, "Verify Your Email Address", htmlBody, cancellationToken);
}
/// <inheritdoc />
public async Task Send2FACodeAsync(string email, string code, CancellationToken cancellationToken = default)
{
var htmlBody = $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<title>2FA Verification Code</title>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #50c878; color: white; padding: 20px; text-align: center; }}
.content {{ padding: 30px; background: #f9f9f9; }}
.code {{ font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #fff; border: 2px dashed #50c878; margin: 20px 0; letter-spacing: 8px; }}
.footer {{ padding: 20px; text-align: center; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class=""container"">
<div class=""header"">
<h1>Two-Factor Authentication</h1>
</div>
<div class=""content"">
<p>Hello,</p>
<p>Your verification code is:</p>
<div class=""code"">{code}</div>
<p>This code will expire in 5 minutes.</p>
<p>If you did not request this code, please secure your account immediately.</p>
</div>
<div class=""footer"">
<p>&copy; {DateTime.UtcNow.Year} IAM Service. All rights reserved.</p>
</div>
</div>
</body>
</html>";
await SendEmailAsync(email, "Your 2FA Verification Code", htmlBody, cancellationToken);
}
/// <inheritdoc />
public async Task SendPasswordResetEmailAsync(string email, string token, CancellationToken cancellationToken = default)
{
var resetUrl = $"{_settings.BaseUrl}/reset-password?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}";
var htmlBody = $@"
<!DOCTYPE html>
<html>
<head>
<meta charset=""utf-8"">
<title>Password Reset</title>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #ff6b6b; color: white; padding: 20px; text-align: center; }}
.content {{ padding: 30px; background: #f9f9f9; }}
.button {{ display: inline-block; padding: 12px 30px; background: #ff6b6b; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }}
.footer {{ padding: 20px; text-align: center; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class=""container"">
<div class=""header"">
<h1>Password Reset</h1>
</div>
<div class=""content"">
<p>Hello,</p>
<p>We received a request to reset your password. Click the button below to proceed:</p>
<p style=""text-align: center;"">
<a href=""{resetUrl}"" class=""button"">Reset Password</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style=""word-break: break-all; font-size: 12px;"">{resetUrl}</p>
<p>This link will expire in 1 hour.</p>
<p>If you did not request a password reset, please ignore this email.</p>
</div>
<div class=""footer"">
<p>&copy; {DateTime.UtcNow.Year} IAM Service. All rights reserved.</p>
</div>
</div>
</body>
</html>";
await SendEmailAsync(email, "Reset Your Password", htmlBody, cancellationToken);
}
}

View File

@@ -28,6 +28,13 @@
<!-- EN: Redis cache / VI: Redis cache -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<!-- EN: Email sending with MailKit / VI: Gửi email với MailKit -->
<PackageReference Include="MailKit" Version="4.8.0" />
<!-- EN: 2FA with TOTP / VI: 2FA với TOTP -->
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<!-- EN: ASP.NET Core Identity / VI: ASP.NET Core Identity -->
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
@@ -38,6 +45,10 @@
<!-- EN: JWT Bearer for API authentication / VI: JWT Bearer cho API authentication -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<!-- EN: Social Login with Google and Facebook / VI: Social Login với Google và Facebook -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Facebook" Version="9.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,117 @@
// EN: Interface for social login service
// VI: Interface cho social login service
using IamService.Domain.AggregatesModel.UserAggregate;
namespace IamService.Infrastructure.SocialLogin;
/// <summary>
/// EN: Result of external authentication.
/// VI: Kết quả xác thực bên ngoài.
/// </summary>
public record ExternalAuthResult
{
/// <summary>
/// EN: Whether authentication was successful.
/// VI: Xác thực có thành công không.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// EN: Error message if authentication failed.
/// VI: Thông báo lỗi nếu xác thực thất bại.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// EN: External provider name (Google, Facebook).
/// VI: Tên provider bên ngoài (Google, Facebook).
/// </summary>
public string? Provider { get; init; }
/// <summary>
/// EN: User ID from external provider.
/// VI: User ID từ provider bên ngoài.
/// </summary>
public string? ProviderUserId { get; init; }
/// <summary>
/// EN: User email from external provider.
/// VI: Email người dùng từ provider bên ngoài.
/// </summary>
public string? Email { get; init; }
/// <summary>
/// EN: User name from external provider.
/// VI: Tên người dùng từ provider bên ngoài.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// EN: Profile picture URL.
/// VI: URL ảnh đại diện.
/// </summary>
public string? PictureUrl { get; init; }
/// <summary>
/// EN: The user if already exists or was created.
/// VI: User nếu đã tồn tại hoặc được tạo mới.
/// </summary>
public ApplicationUser? User { get; init; }
/// <summary>
/// EN: Whether this is a new user registration.
/// VI: Đây có phải là đăng ký người dùng mới không.
/// </summary>
public bool IsNewUser { get; init; }
}
/// <summary>
/// EN: Interface for handling social login operations.
/// VI: Interface xử lý các thao tác social login.
/// </summary>
public interface ISocialLoginService
{
/// <summary>
/// EN: Process external login callback and create/link user.
/// VI: Xử lý callback đăng nhập bên ngoài và tạo/liên kết user.
/// </summary>
/// <param name="provider">Provider name (Google, Facebook)</param>
/// <param name="providerUserId">User ID from provider</param>
/// <param name="email">User email</param>
/// <param name="name">User name</param>
/// <param name="pictureUrl">Profile picture URL</param>
/// <returns>External authentication result</returns>
Task<ExternalAuthResult> ProcessExternalLoginAsync(
string provider,
string providerUserId,
string email,
string? name,
string? pictureUrl);
/// <summary>
/// EN: Link external account to existing user.
/// VI: Liên kết tài khoản bên ngoài với user hiện tại.
/// </summary>
/// <param name="userId">Existing user ID</param>
/// <param name="provider">Provider name</param>
/// <param name="providerUserId">User ID from provider</param>
/// <returns>Success result</returns>
Task<bool> LinkExternalAccountAsync(Guid userId, string provider, string providerUserId);
/// <summary>
/// EN: Unlink external account from user.
/// VI: Hủy liên kết tài khoản bên ngoài khỏi user.
/// </summary>
/// <param name="userId">User ID</param>
/// <param name="provider">Provider name</param>
/// <returns>Success result</returns>
Task<bool> UnlinkExternalAccountAsync(Guid userId, string provider);
/// <summary>
/// EN: Get linked external accounts for user.
/// VI: Lấy danh sách tài khoản bên ngoài đã liên kết của user.
/// </summary>
/// <param name="userId">User ID</param>
/// <returns>List of provider names</returns>
Task<IEnumerable<string>> GetLinkedProvidersAsync(Guid userId);
}

View File

@@ -0,0 +1,232 @@
// EN: Implementation of social login service
// VI: Implementation của social login service
using IamService.Domain.AggregatesModel.UserAggregate;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace IamService.Infrastructure.SocialLogin;
/// <summary>
/// EN: Service for handling social login operations.
/// VI: Service xử lý các thao tác social login.
/// </summary>
public class SocialLoginService : ISocialLoginService
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<SocialLoginService> _logger;
public SocialLoginService(
UserManager<ApplicationUser> userManager,
ILogger<SocialLoginService> logger)
{
_userManager = userManager;
_logger = logger;
}
/// <inheritdoc />
public async Task<ExternalAuthResult> ProcessExternalLoginAsync(
string provider,
string providerUserId,
string email,
string? name,
string? pictureUrl)
{
_logger.LogInformation(
"EN: Processing external login for provider {Provider}, email {Email} / VI: Xử lý đăng nhập bên ngoài cho provider {Provider}, email {Email}",
provider, email);
try
{
// EN: Try to find user by external login
// VI: Thử tìm user bằng external login
var user = await _userManager.FindByLoginAsync(provider, providerUserId);
if (user != null)
{
_logger.LogInformation(
"EN: Found existing user by external login / VI: Tìm thấy user hiện có bằng external login");
return new ExternalAuthResult
{
Success = true,
Provider = provider,
ProviderUserId = providerUserId,
Email = email,
Name = name,
PictureUrl = pictureUrl,
User = user,
IsNewUser = false
};
}
// EN: Try to find user by email
// VI: Thử tìm user bằng email
user = await _userManager.FindByEmailAsync(email);
if (user != null)
{
// EN: Link external login to existing user
// VI: Liên kết external login với user hiện có
var loginInfo = new UserLoginInfo(provider, providerUserId, provider);
var linkResult = await _userManager.AddLoginAsync(user, loginInfo);
if (!linkResult.Succeeded)
{
var errors = string.Join(", ", linkResult.Errors.Select(e => e.Description));
_logger.LogWarning(
"EN: Failed to link external login: {Errors} / VI: Không thể liên kết external login: {Errors}",
errors);
return new ExternalAuthResult
{
Success = false,
ErrorMessage = $"Failed to link external account: {errors}"
};
}
_logger.LogInformation(
"EN: Linked external login to existing user / VI: Đã liên kết external login với user hiện có");
return new ExternalAuthResult
{
Success = true,
Provider = provider,
ProviderUserId = providerUserId,
Email = email,
Name = name,
PictureUrl = pictureUrl,
User = user,
IsNewUser = false
};
}
// EN: Create new user
// VI: Tạo user mới
var firstName = name?.Split(' ').FirstOrDefault() ?? "User";
var lastName = name?.Split(' ').Skip(1).FirstOrDefault() ?? email.Split('@').First();
user = new ApplicationUser(email, firstName, lastName)
{
EmailConfirmed = true // EN: Email verified by provider / VI: Email đã được provider xác minh
};
var createResult = await _userManager.CreateAsync(user);
if (!createResult.Succeeded)
{
var errors = string.Join(", ", createResult.Errors.Select(e => e.Description));
_logger.LogWarning(
"EN: Failed to create user: {Errors} / VI: Không thể tạo user: {Errors}",
errors);
return new ExternalAuthResult
{
Success = false,
ErrorMessage = $"Failed to create user: {errors}"
};
}
// EN: Add external login to new user
// VI: Thêm external login cho user mới
var addLoginInfo = new UserLoginInfo(provider, providerUserId, provider);
var addLoginResult = await _userManager.AddLoginAsync(user, addLoginInfo);
if (!addLoginResult.Succeeded)
{
_logger.LogWarning(
"EN: Failed to add external login for new user / VI: Không thể thêm external login cho user mới");
}
// EN: Add default role
// VI: Thêm role mặc định
await _userManager.AddToRoleAsync(user, "User");
_logger.LogInformation(
"EN: Created new user from external login / VI: Đã tạo user mới từ external login");
return new ExternalAuthResult
{
Success = true,
Provider = provider,
ProviderUserId = providerUserId,
Email = email,
Name = name,
PictureUrl = pictureUrl,
User = user,
IsNewUser = true
};
}
catch (Exception ex)
{
_logger.LogError(ex,
"EN: Error processing external login / VI: Lỗi xử lý external login");
return new ExternalAuthResult
{
Success = false,
ErrorMessage = "An error occurred during external authentication"
};
}
}
/// <inheritdoc />
public async Task<bool> LinkExternalAccountAsync(Guid userId, string provider, string providerUserId)
{
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user == null)
{
_logger.LogWarning("EN: User not found / VI: Không tìm thấy user");
return false;
}
var loginInfo = new UserLoginInfo(provider, providerUserId, provider);
var result = await _userManager.AddLoginAsync(user, loginInfo);
if (!result.Succeeded)
{
_logger.LogWarning(
"EN: Failed to link external account / VI: Không thể liên kết tài khoản bên ngoài");
return false;
}
return true;
}
/// <inheritdoc />
public async Task<bool> UnlinkExternalAccountAsync(Guid userId, string provider)
{
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user == null)
{
_logger.LogWarning("EN: User not found / VI: Không tìm thấy user");
return false;
}
// EN: Get the provider user ID
// VI: Lấy provider user ID
var logins = await _userManager.GetLoginsAsync(user);
var login = logins.FirstOrDefault(l => l.LoginProvider == provider);
if (login == null)
{
_logger.LogWarning(
"EN: External login not found / VI: Không tìm thấy external login");
return false;
}
var result = await _userManager.RemoveLoginAsync(user, provider, login.ProviderKey);
return result.Succeeded;
}
/// <inheritdoc />
public async Task<IEnumerable<string>> GetLinkedProvidersAsync(Guid userId)
{
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user == null)
{
return Enumerable.Empty<string>();
}
var logins = await _userManager.GetLoginsAsync(user);
return logins.Select(l => l.LoginProvider);
}
}

View File

@@ -0,0 +1,66 @@
// EN: Social login settings for external OAuth providers
// VI: Cấu hình social login cho các OAuth providers bên ngoài
namespace IamService.Infrastructure.SocialLogin;
/// <summary>
/// EN: Configuration settings for Google OAuth.
/// VI: Cấu hình cho Google OAuth.
/// </summary>
public class GoogleAuthSettings
{
/// <summary>
/// EN: Google OAuth Client ID.
/// VI: Client ID của Google OAuth.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// EN: Google OAuth Client Secret.
/// VI: Client Secret của Google OAuth.
/// </summary>
public string ClientSecret { get; set; } = string.Empty;
}
/// <summary>
/// EN: Configuration settings for Facebook OAuth.
/// VI: Cấu hình cho Facebook OAuth.
/// </summary>
public class FacebookAuthSettings
{
/// <summary>
/// EN: Facebook App ID.
/// VI: App ID của Facebook.
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// EN: Facebook App Secret.
/// VI: App Secret của Facebook.
/// </summary>
public string AppSecret { get; set; } = string.Empty;
}
/// <summary>
/// EN: Combined social login settings.
/// VI: Cấu hình tổng hợp social login.
/// </summary>
public class SocialLoginSettings
{
/// <summary>
/// EN: Google OAuth settings.
/// VI: Cấu hình Google OAuth.
/// </summary>
public GoogleAuthSettings Google { get; set; } = new();
/// <summary>
/// EN: Facebook OAuth settings.
/// VI: Cấu hình Facebook OAuth.
/// </summary>
public FacebookAuthSettings Facebook { get; set; } = new();
/// <summary>
/// EN: Callback URL after external authentication.
/// VI: URL callback sau khi xác thực bên ngoài.
/// </summary>
public string CallbackUrl { get; set; } = "/api/auth/external-callback";
}

View File

@@ -0,0 +1,41 @@
// EN: Two-factor authentication service interface
// VI: Interface service xác thực hai yếu tố
namespace IamService.Infrastructure.TwoFactor;
/// <summary>
/// EN: Two-factor authentication service interface.
/// VI: Interface service xác thực hai yếu tố.
/// </summary>
public interface ITwoFactorService
{
/// <summary>
/// EN: Generate a new secret key for 2FA.
/// VI: Tạo secret key mới cho 2FA.
/// </summary>
string GenerateSecretKey();
/// <summary>
/// EN: Generate QR code URI for authenticator apps.
/// VI: Tạo URI QR code cho ứng dụng authenticator.
/// </summary>
string GenerateQrCodeUri(string email, string secretKey);
/// <summary>
/// EN: Generate QR code as base64 image.
/// VI: Tạo QR code dưới dạng ảnh base64.
/// </summary>
string GenerateQrCodeBase64(string email, string secretKey);
/// <summary>
/// EN: Validate TOTP code against secret key.
/// VI: Xác thực mã TOTP với secret key.
/// </summary>
bool ValidateCode(string secretKey, string code);
/// <summary>
/// EN: Generate recovery codes.
/// VI: Tạo mã khôi phục.
/// </summary>
IEnumerable<string> GenerateRecoveryCodes(int count = 10);
}

View File

@@ -0,0 +1,122 @@
// EN: TOTP-based two-factor authentication service
// VI: Service xác thực hai yếu tố dựa trên TOTP
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OtpNet;
using QRCoder;
namespace IamService.Infrastructure.TwoFactor;
/// <summary>
/// EN: TOTP-based two-factor authentication service implementation.
/// VI: Implementation service 2FA dựa trên TOTP.
/// </summary>
public class TotpTwoFactorService : ITwoFactorService
{
private readonly TwoFactorSettings _settings;
private readonly ILogger<TotpTwoFactorService> _logger;
public TotpTwoFactorService(
IOptions<TwoFactorSettings> settings,
ILogger<TotpTwoFactorService> logger)
{
_settings = settings.Value;
_logger = logger;
}
/// <inheritdoc />
public string GenerateSecretKey()
{
// EN: Generate 20 bytes (160 bits) secret key for TOTP
// VI: Tạo secret key 20 bytes (160 bits) cho TOTP
var key = KeyGeneration.GenerateRandomKey(20);
return Base32Encoding.ToString(key);
}
/// <inheritdoc />
public string GenerateQrCodeUri(string email, string secretKey)
{
// EN: Format: otpauth://totp/{issuer}:{email}?secret={secret}&issuer={issuer}
// VI: Format: otpauth://totp/{issuer}:{email}?secret={secret}&issuer={issuer}
var issuer = Uri.EscapeDataString(_settings.Issuer);
var accountName = Uri.EscapeDataString(email);
return $"otpauth://totp/{issuer}:{accountName}?secret={secretKey}&issuer={issuer}&digits={_settings.CodeLength}&period={_settings.ValidityPeriodSeconds}";
}
/// <inheritdoc />
public string GenerateQrCodeBase64(string email, string secretKey)
{
try
{
var uri = GenerateQrCodeUri(email, secretKey);
using var qrGenerator = new QRCodeGenerator();
var qrCodeData = qrGenerator.CreateQrCode(uri, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new PngByteQRCode(qrCodeData);
var pngBytes = qrCode.GetGraphic(10);
return $"data:image/png;base64,{Convert.ToBase64String(pngBytes)}";
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate QR code for {Email}", email);
throw;
}
}
/// <inheritdoc />
public bool ValidateCode(string secretKey, string code)
{
try
{
if (string.IsNullOrWhiteSpace(secretKey) || string.IsNullOrWhiteSpace(code))
{
return false;
}
var secretBytes = Base32Encoding.ToBytes(secretKey);
var totp = new Totp(secretBytes, step: _settings.ValidityPeriodSeconds);
// EN: Allow 1 step tolerance (previous and next period)
// VI: Cho phép dung sai 1 bước (kỳ trước và sau)
var isValid = totp.VerifyTotp(code, out _, new VerificationWindow(previous: 1, future: 1));
_logger.LogDebug("TOTP validation result: {IsValid}", isValid);
return isValid;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to validate TOTP code");
return false;
}
}
/// <inheritdoc />
public IEnumerable<string> GenerateRecoveryCodes(int count = 10)
{
var codes = new List<string>();
for (int i = 0; i < count; i++)
{
// EN: Generate 8 character alphanumeric recovery code
// VI: Tạo mã khôi phục 8 ký tự chữ và số
var bytes = RandomNumberGenerator.GetBytes(6);
var code = Convert.ToBase64String(bytes)
.Replace("+", "")
.Replace("/", "")
.Replace("=", "")
.Substring(0, 8)
.ToUpperInvariant();
// EN: Format as XXXX-XXXX for readability
// VI: Format thành XXXX-XXXX để dễ đọc
codes.Add($"{code.Substring(0, 4)}-{code.Substring(4, 4)}");
}
return codes;
}
}

View File

@@ -0,0 +1,31 @@
// EN: Two-factor authentication settings
// VI: Cấu hình xác thực hai yếu tố
namespace IamService.Infrastructure.TwoFactor;
/// <summary>
/// EN: Settings for two-factor authentication.
/// VI: Cấu hình cho xác thực hai yếu tố.
/// </summary>
public class TwoFactorSettings
{
public const string SectionName = "TwoFactor";
/// <summary>
/// EN: Issuer name displayed in authenticator apps.
/// VI: Tên issuer hiển thị trong ứng dụng authenticator.
/// </summary>
public string Issuer { get; set; } = "IAM Service";
/// <summary>
/// EN: Code length (default 6 digits).
/// VI: Độ dài mã (mặc định 6 chữ số).
/// </summary>
public int CodeLength { get; set; } = 6;
/// <summary>
/// EN: Validity period in seconds (default 30s for TOTP).
/// VI: Thời gian hiệu lực tính bằng giây (mặc định 30s cho TOTP).
/// </summary>
public int ValidityPeriodSeconds { get; set; } = 30;
}

View File

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

75
services/storage-service-net/.gitignore vendored Normal file
View File

@@ -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
~$*

View File

@@ -0,0 +1,22 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591;CA2017</NoWarn>
</PropertyGroup>
<PropertyGroup>
<Authors>GoodGo Team</Authors>
<Company>GoodGo</Company>
<Copyright>© 2026 GoodGo. All rights reserved.</Copyright>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
</Project>

View File

@@ -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/StorageService.API/StorageService.API.csproj", "src/StorageService.API/"]
COPY ["src/StorageService.Domain/StorageService.Domain.csproj", "src/StorageService.Domain/"]
COPY ["src/StorageService.Infrastructure/StorageService.Infrastructure.csproj", "src/StorageService.Infrastructure/"]
COPY ["Directory.Build.props", "./"]
# EN: Restore dependencies
# VI: Khôi phục dependencies
RUN dotnet restore "src/StorageService.API/StorageService.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/StorageService.API"
RUN dotnet build "StorageService.API.csproj" -c Release -o /app/build --no-restore
# Publish stage / Giai đoạn publish
FROM build AS publish
RUN dotnet publish "StorageService.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", "StorageService.API.dll"]

View File

@@ -0,0 +1,95 @@
# Storage Service .NET / Dịch vụ Lưu trữ .NET
EN: Microservice for file storage management supporting MinIO and Aliyun OSS.
VI: Microservice quản lý lưu trữ file hỗ trợ MinIO và Aliyun OSS.
## Features / Tính năng
- **Multi-provider storage**: MinIO (S3-compatible) and Aliyun OSS
- **Provider switching**: Switch providers via environment variable
- **File CRUD operations**: Upload, download, delete, list files
- **Pre-signed URLs**: Secure time-limited download/upload URLs
- **User quotas**: Storage capacity and file count limits
- **Inter-service auth**: JWT validation via IAM Service
## Architecture / Kiến trúc
```
├── src/
│ ├── StorageService.API/ # Controllers, Commands, Queries
│ ├── StorageService.Domain/ # Entities, Repository interfaces
│ └── StorageService.Infrastructure/# Providers, DbContext, Repositories
└── tests/
├── StorageService.UnitTests/
└── StorageService.FunctionalTests/
```
## Quick Start / Bắt đầu nhanh
### Prerequisites / Yêu cầu
- .NET 10 SDK
- Docker & Docker Compose
- PostgreSQL (or Neon)
- MinIO (or Aliyun OSS)
### Run with Docker / Chạy với Docker
```bash
cd services/storage-service-net
docker-compose up -d
```
Access at: http://localhost:5002/swagger
### Run locally / Chạy local
```bash
cd services/storage-service-net
dotnet run --project src/StorageService.API
```
## Configuration / Cấu hình
| Variable | Description | Default |
|----------|-------------|---------|
| `Storage__Provider` | Provider: `minio` or `aliyun` | `minio` |
| `Storage__DefaultBucket` | Default bucket name | `storage` |
| `Storage__MinIO__Endpoint` | MinIO endpoint | `localhost:9000` |
| `Storage__MinIO__AccessKey` | MinIO access key | - |
| `Storage__MinIO__SecretKey` | MinIO secret key | - |
| `Storage__AliyunOSS__Endpoint` | OSS endpoint | - |
| `Storage__AliyunOSS__AccessKeyId` | OSS access key | - |
| `Storage__AliyunOSS__AccessKeySecret` | OSS secret key | - |
| `IamService__BaseUrl` | IAM Service URL | `http://localhost:5001` |
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/files/upload` | Upload file |
| `GET` | `/api/v1/files` | List user files |
| `GET` | `/api/v1/files/{id}` | Get file by ID |
| `GET` | `/api/v1/files/{id}/download-url` | Get pre-signed download URL |
| `DELETE` | `/api/v1/files/{id}` | Delete file |
| `GET` | `/api/v1/quota` | Get user quota |
## Database Migrations / Migration Database
```bash
cd services/storage-service-net
dotnet ef migrations add InitialCreate --project src/StorageService.Infrastructure --startup-project src/StorageService.API
dotnet ef database update --project src/StorageService.Infrastructure --startup-project src/StorageService.API
```
## Testing / Kiểm thử
```bash
# Unit tests
dotnet test tests/StorageService.UnitTests
# All tests
dotnet test
```
## License
MIT

View File

@@ -0,0 +1,11 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/StorageService.API/StorageService.API.csproj" />
<Project Path="src/StorageService.Domain/StorageService.Domain.csproj" />
<Project Path="src/StorageService.Infrastructure/StorageService.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/StorageService.FunctionalTests/StorageService.FunctionalTests.csproj" />
<Project Path="tests/StorageService.UnitTests/StorageService.UnitTests.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,102 @@
version: '3.8'
# EN: Docker Compose for local development
# VI: Docker Compose cho phát triển local
services:
storage-api:
build:
context: .
dockerfile: Dockerfile
container_name: storage-service-api
ports:
- "5002:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- DATABASE_URL=Host=postgres;Port=5432;Database=storage_db;Username=postgres;Password=postgres
- REDIS_URL=redis:6379
- Storage__Provider=minio
- Storage__DefaultBucket=storage
- Storage__MinIO__Endpoint=minio:9000
- Storage__MinIO__AccessKey=minioadmin
- Storage__MinIO__SecretKey=minioadmin
- Storage__MinIO__UseSSL=false
- IamService__BaseUrl=http://iam-service:5001
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
networks:
- storage-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: storage-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: storage_db
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- storage-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: storage-redis
ports:
- "6380:6379"
volumes:
- redis_data:/data
networks:
- storage-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
container_name: storage-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000" # API port
- "9001:9001" # Console port
volumes:
- minio_data:/data
networks:
- storage-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
minio_data:
networks:
storage-network:
driver: bridge

View File

@@ -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 (MyService.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 (MyService.Infrastructure)
Technical implementations and external concerns:
- Database access (EF Core)
- Repository implementations
- External service integrations
### 3. API Layer (MyService.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:
myservice-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: myservice-api
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: myservice: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)

View File

@@ -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 "MyService" to "YourService"
find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} +
find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/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 myservice-api
```
### 4. Run Locally
```bash
# Restore dependencies
dotnet restore
# Build all projects
dotnet build
# Run the API
dotnet run --project src/MyService.API
```
## Project Structure
```
_template_dot_net/
├── src/
│ ├── MyService.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
│ │
│ ├── MyService.Domain/ # Domain Layer (Pure business logic)
│ │ ├── AggregatesModel/ # Aggregate roots and entities
│ │ ├── Events/ # Domain events
│ │ ├── Exceptions/ # Domain exceptions
│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.)
│ │
│ └── MyService.Infrastructure/ # Infrastructure Layer (Data access)
│ ├── EntityConfigurations/ # EF Core Fluent API configurations
│ ├── Repositories/ # Repository implementations
│ ├── Idempotency/ # Request idempotency handling
│ └── MyServiceContext.cs # DbContext with Unit of Work
├── tests/
│ ├── MyService.UnitTests/ # Unit tests (Domain, Application)
│ └── MyService.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<CreateSampleCommandResult>;
// Handle command
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
{
public async Task<CreateSampleCommandResult> Handle(CreateSampleCommand request, CancellationToken ct)
{
var sample = new Sample(request.Name, request.Description);
_repository.Add(sample);
await _repository.UnitOfWork.SaveEntitiesAsync(ct);
return new CreateSampleCommandResult(sample.Id);
}
}
```
### Queries (Read Operations)
```csharp
// Define query
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
```
## Domain Model
### Aggregate Root
```csharp
public class Sample : Entity, IAggregateRoot
{
public string Name => _name;
public SampleStatus Status => _status;
public Sample(string name, string? description) {
// 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/MyService.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=myservice;Username=postgres;Password=postgres"
},
"Serilog": {
"MinimumLevel": "Information"
}
}
```
## Deployment
### Docker Build
```bash
# Build Docker image
docker build -t myservice:latest .
# Run container
docker run -p 5000:8080 --env-file .env myservice: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

View File

@@ -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 (MyService.Domain)
Trái tim của ứng dụng chứa business logic thuần túy. Lớp này:
-**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 (MyService.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 (MyService.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:
myservice-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: myservice-api
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: myservice: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)

View File

@@ -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ả "MyService" thành "YourService"
find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} +
find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/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 myservice-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/MyService.API
```
## Cấu Trúc Dự Án
```
_template_dot_net/
├── src/
│ ├── MyService.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
│ │
│ ├── MyService.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.)
│ │
│ └── MyService.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
│ └── MyServiceContext.cs # DbContext với Unit of Work
├── tests/
│ ├── MyService.UnitTests/ # Unit tests (Domain, Application)
│ └── MyService.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<CreateSampleCommandResult>;
// Xử lý command
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
{
public async Task<CreateSampleCommandResult> Handle(CreateSampleCommand request, CancellationToken ct)
{
var sample = new Sample(request.Name, request.Description);
_repository.Add(sample);
await _repository.UnitOfWork.SaveEntitiesAsync(ct);
return new CreateSampleCommandResult(sample.Id);
}
}
```
### Queries (Thao Tác Đọc)
```csharp
// Định nghĩa query
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
```
## Domain Model
### Aggregate Root
```csharp
public class Sample : Entity, IAggregateRoot
{
public string Name => _name;
public SampleStatus Status => _status;
public Sample(string name, string? description) {
// Validation business logic
if (string.IsNullOrWhiteSpace(name))
throw new SampleDomainException("Tên sample không được để trống");
// Domain event
AddDomainEvent(new SampleCreatedDomainEvent(this));
}
public void Activate() {
if (_status != SampleStatus.Draft)
throw new SampleDomainException("Chỉ sample draft mới có thể kích hoạt");
// Chuyển đổi trạng thái
}
}
```
## Kiểm Thử
```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/MyService.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=myservice;Username=postgres;Password=postgres"
},
"Serilog": {
"MinimumLevel": "Information"
}
}
```
## Triển Khai
### Docker Build
```bash
# Build Docker image
docker build -t myservice:latest .
# Chạy container
docker run -p 5000:8080 --env-file .env myservice: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

View File

@@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.101",
"rollForward": "latestMinor",
"allowPrerelease": false
}
}

View File

@@ -0,0 +1,58 @@
using System.Diagnostics;
using MediatR;
namespace StorageService.API.Application.Behaviors;
/// <summary>
/// EN: MediatR behavior for logging request handling.
/// VI: MediatR behavior để logging việc xử lý request.
/// </summary>
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> 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;
}
}
}

View File

@@ -0,0 +1,84 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using StorageService.Infrastructure;
namespace StorageService.API.Application.Behaviors;
/// <summary>
/// EN: MediatR behavior for handling database transactions.
/// VI: MediatR behavior để xử lý database transactions.
/// </summary>
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly StorageServiceContext _dbContext;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
StorageServiceContext dbContext,
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> 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);
_dbContext.RollbackTransaction();
throw;
}
});
}
}

View File

@@ -0,0 +1,63 @@
using FluentValidation;
using MediatR;
namespace StorageService.API.Application.Behaviors;
/// <summary>
/// EN: MediatR behavior for FluentValidation integration.
/// VI: MediatR behavior để tích hợp FluentValidation.
/// </summary>
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
public class ValidatorBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
private readonly ILogger<ValidatorBehavior<TRequest, TResponse>> _logger;
public ValidatorBehavior(
IEnumerable<IValidator<TRequest>> validators,
ILogger<ValidatorBehavior<TRequest, TResponse>> logger)
{
_validators = validators ?? throw new ArgumentNullException(nameof(validators));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> 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<TRequest>(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();
}
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Command to change status of a Sample.
/// VI: Command để thay đổi trạng thái của Sample.
/// </summary>
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
/// <param name="NewStatus">EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)</param>
public record ChangeSampleStatusCommand(
Guid SampleId,
string NewStatus
) : IRequest<bool>;

View File

@@ -0,0 +1,70 @@
using MediatR;
using StorageService.Domain.AggregatesModel.SampleAggregate;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Handler for ChangeSampleStatusCommand.
/// VI: Handler cho ChangeSampleStatusCommand.
/// </summary>
public class ChangeSampleStatusCommandHandler : IRequestHandler<ChangeSampleStatusCommand, bool>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<ChangeSampleStatusCommandHandler> _logger;
public ChangeSampleStatusCommandHandler(
ISampleRepository sampleRepository,
ILogger<ChangeSampleStatusCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> Handle(
ChangeSampleStatusCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}",
request.SampleId, request.NewStatus);
// EN: Get existing sample / VI: Lấy sample đã tồn tại
var sample = await _sampleRepository.GetAsync(request.SampleId);
if (sample is null)
{
_logger.LogWarning(
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
request.SampleId);
return false;
}
// EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action
switch (request.NewStatus.ToLowerInvariant())
{
case "activate":
sample.Activate();
break;
case "complete":
sample.Complete();
break;
case "cancel":
sample.Cancel();
break;
default:
_logger.LogWarning(
"Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}",
request.NewStatus);
return false;
}
// EN: Save changes / VI: Lưu thay đổi
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}",
request.SampleId, request.NewStatus);
return true;
}
}

View File

@@ -0,0 +1,21 @@
using MediatR;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Command to create a new Sample.
/// VI: Command để tạo một Sample mới.
/// </summary>
/// <param name="Name">EN: Sample name / VI: Tên sample</param>
/// <param name="Description">EN: Optional description / VI: Mô tả tùy chọn</param>
public record CreateSampleCommand(
string Name,
string? Description
) : IRequest<CreateSampleCommandResult>;
/// <summary>
/// EN: Result of CreateSampleCommand.
/// VI: Kết quả của CreateSampleCommand.
/// </summary>
/// <param name="Id">EN: Created sample ID / VI: ID sample đã tạo</param>
public record CreateSampleCommandResult(Guid Id);

View File

@@ -0,0 +1,46 @@
using MediatR;
using StorageService.Domain.AggregatesModel.SampleAggregate;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Handler for CreateSampleCommand.
/// VI: Handler cho CreateSampleCommand.
/// </summary>
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<CreateSampleCommandHandler> _logger;
public CreateSampleCommandHandler(
ISampleRepository sampleRepository,
ILogger<CreateSampleCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CreateSampleCommandResult> Handle(
CreateSampleCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}",
request.Name);
// EN: Create domain entity / VI: Tạo domain entity
var sample = new Sample(request.Name, request.Description);
// EN: Add to repository / VI: Thêm vào repository
_sampleRepository.Add(sample);
// EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events)
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}",
sample.Id);
return new CreateSampleCommandResult(sample.Id);
}
}

View File

@@ -0,0 +1,20 @@
using MediatR;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Command to delete a file.
/// VI: Command để xóa file.
/// </summary>
public record DeleteFileCommand(
Guid FileId,
string UserId
) : IRequest<DeleteFileResult>;
/// <summary>
/// EN: Result of file deletion.
/// VI: Kết quả xóa file.
/// </summary>
public record DeleteFileResult(
bool Success,
string? Error);

View File

@@ -0,0 +1,84 @@
using MediatR;
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Domain.AggregatesModel.QuotaAggregate;
using StorageService.Infrastructure.Storage;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Handler for DeleteFileCommand.
/// VI: Handler cho DeleteFileCommand.
/// </summary>
public class DeleteFileCommandHandler : IRequestHandler<DeleteFileCommand, DeleteFileResult>
{
private readonly IFileRepository _fileRepository;
private readonly IQuotaRepository _quotaRepository;
private readonly IStorageProviderFactory _storageProviderFactory;
private readonly ILogger<DeleteFileCommandHandler> _logger;
public DeleteFileCommandHandler(
IFileRepository fileRepository,
IQuotaRepository quotaRepository,
IStorageProviderFactory storageProviderFactory,
ILogger<DeleteFileCommandHandler> logger)
{
_fileRepository = fileRepository;
_quotaRepository = quotaRepository;
_storageProviderFactory = storageProviderFactory;
_logger = logger;
}
public async Task<DeleteFileResult> Handle(DeleteFileCommand request, CancellationToken cancellationToken)
{
try
{
// EN: Get file metadata / VI: Lấy metadata file
var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken);
if (file == null)
{
return new DeleteFileResult(false, "File not found.");
}
// EN: Check ownership / VI: Kiểm tra quyền sở hữu
if (file.UserId != request.UserId)
{
return new DeleteFileResult(false, "You don't have permission to delete this file.");
}
// EN: Delete from storage provider / VI: Xóa khỏi storage provider
var provider = _storageProviderFactory.GetProvider(file.Provider);
var deleted = await provider.DeleteAsync(file.BucketName, file.ObjectKey, cancellationToken);
if (!deleted)
{
_logger.LogWarning("Failed to delete file from storage: {FileId}", request.FileId);
// EN: Continue with soft delete even if storage delete fails
// VI: Tiếp tục soft delete ngay cả khi xóa storage thất bại
}
// EN: Soft delete file record / VI: Soft delete record file
file.Delete();
_fileRepository.Update(file);
// EN: Update quota / VI: Cập nhật quota
var quota = await _quotaRepository.GetByUserIdAsync(request.UserId, cancellationToken);
if (quota != null)
{
quota.RemoveUsage(file.FileSizeBytes);
_quotaRepository.Update(quota);
}
// EN: Save changes / VI: Lưu thay đổi
await _fileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("File deleted successfully: {FileId}", request.FileId);
return new DeleteFileResult(true, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting file {FileId}", request.FileId);
return new DeleteFileResult(false, "An error occurred while deleting the file.");
}
}
}

View File

@@ -0,0 +1,10 @@
using MediatR;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Command to delete a Sample.
/// VI: Command để xóa một Sample.
/// </summary>
/// <param name="SampleId">EN: Sample ID to delete / VI: ID sample cần xóa</param>
public record DeleteSampleCommand(Guid SampleId) : IRequest<bool>;

View File

@@ -0,0 +1,54 @@
using MediatR;
using StorageService.Domain.AggregatesModel.SampleAggregate;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Handler for DeleteSampleCommand.
/// VI: Handler cho DeleteSampleCommand.
/// </summary>
public class DeleteSampleCommandHandler : IRequestHandler<DeleteSampleCommand, bool>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<DeleteSampleCommandHandler> _logger;
public DeleteSampleCommandHandler(
ISampleRepository sampleRepository,
ILogger<DeleteSampleCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> Handle(
DeleteSampleCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Deleting sample {SampleId} / Xóa sample {SampleId}",
request.SampleId);
// EN: Get existing sample / VI: Lấy sample đã tồn tại
var sample = await _sampleRepository.GetAsync(request.SampleId);
if (sample is null)
{
_logger.LogWarning(
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
request.SampleId);
return false;
}
// EN: Delete sample / VI: Xóa sample
_sampleRepository.Delete(sample);
// EN: Save changes / VI: Lưu thay đổi
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công",
request.SampleId);
return true;
}
}

View File

@@ -0,0 +1,16 @@
using MediatR;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Command to update an existing Sample.
/// VI: Command để cập nhật một Sample đã tồn tại.
/// </summary>
/// <param name="SampleId">EN: Sample ID to update / VI: ID sample cần cập nhật</param>
/// <param name="Name">EN: New name / VI: Tên mới</param>
/// <param name="Description">EN: New description / VI: Mô tả mới</param>
public record UpdateSampleCommand(
Guid SampleId,
string Name,
string? Description
) : IRequest<bool>;

View File

@@ -0,0 +1,54 @@
using MediatR;
using StorageService.Domain.AggregatesModel.SampleAggregate;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Handler for UpdateSampleCommand.
/// VI: Handler cho UpdateSampleCommand.
/// </summary>
public class UpdateSampleCommandHandler : IRequestHandler<UpdateSampleCommand, bool>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<UpdateSampleCommandHandler> _logger;
public UpdateSampleCommandHandler(
ISampleRepository sampleRepository,
ILogger<UpdateSampleCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> Handle(
UpdateSampleCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Updating sample {SampleId} / Cập nhật sample {SampleId}",
request.SampleId);
// EN: Get existing sample / VI: Lấy sample đã tồn tại
var sample = await _sampleRepository.GetAsync(request.SampleId);
if (sample is null)
{
_logger.LogWarning(
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
request.SampleId);
return false;
}
// EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method
sample.Update(request.Name, request.Description);
// EN: Save changes / VI: Lưu thay đổi
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công",
request.SampleId);
return true;
}
}

View File

@@ -0,0 +1,28 @@
using MediatR;
using StorageService.Domain.AggregatesModel.FileAggregate;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Command to upload a file.
/// VI: Command để upload file.
/// </summary>
public record UploadFileCommand(
Stream FileStream,
string FileName,
string ContentType,
long FileSizeBytes,
string UserId,
string? TenantId = null,
FileAccessLevel AccessLevel = FileAccessLevel.Private
) : IRequest<UploadFileResult>;
/// <summary>
/// EN: Result of file upload.
/// VI: Kết quả upload file.
/// </summary>
public record UploadFileResult(
bool Success,
Guid? FileId,
string? ObjectKey,
string? Error);

View File

@@ -0,0 +1,121 @@
using MediatR;
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Domain.AggregatesModel.QuotaAggregate;
using StorageService.Infrastructure.Configuration;
using StorageService.Infrastructure.Storage;
using Microsoft.Extensions.Options;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Handler for UploadFileCommand.
/// VI: Handler cho UploadFileCommand.
/// </summary>
public class UploadFileCommandHandler : IRequestHandler<UploadFileCommand, UploadFileResult>
{
private readonly IFileRepository _fileRepository;
private readonly IQuotaRepository _quotaRepository;
private readonly IStorageProviderFactory _storageProviderFactory;
private readonly StorageSettings _settings;
private readonly ILogger<UploadFileCommandHandler> _logger;
public UploadFileCommandHandler(
IFileRepository fileRepository,
IQuotaRepository quotaRepository,
IStorageProviderFactory storageProviderFactory,
IOptions<StorageSettings> settings,
ILogger<UploadFileCommandHandler> logger)
{
_fileRepository = fileRepository;
_quotaRepository = quotaRepository;
_storageProviderFactory = storageProviderFactory;
_settings = settings.Value;
_logger = logger;
}
public async Task<UploadFileResult> Handle(UploadFileCommand request, CancellationToken cancellationToken)
{
try
{
// EN: Check file size limit / VI: Kiểm tra giới hạn kích thước file
if (request.FileSizeBytes > _settings.MaxFileSizeBytes)
{
return new UploadFileResult(false, null, null,
$"File size exceeds maximum allowed ({_settings.MaxFileSizeBytes} bytes)");
}
// EN: Check user quota / VI: Kiểm tra quota user
var quota = await _quotaRepository.GetOrCreateAsync(request.UserId, cancellationToken);
if (!quota.CanUpload(request.FileSizeBytes))
{
return new UploadFileResult(false, null, null,
"Quota exceeded. Please upgrade your plan or delete some files.");
}
// EN: Generate object key / VI: Tạo object key
var objectKey = GenerateObjectKey(request.UserId, request.FileName);
var bucketName = _settings.DefaultBucket;
// EN: Upload to storage provider / VI: Upload lên storage provider
var provider = _storageProviderFactory.GetProvider();
var uploadResult = await provider.UploadAsync(
bucketName,
objectKey,
request.FileStream,
request.ContentType,
cancellationToken);
if (!uploadResult.Success)
{
_logger.LogError("Failed to upload file to storage: {Error}", uploadResult.Error);
return new UploadFileResult(false, null, null, uploadResult.Error);
}
// EN: Save file metadata / VI: Lưu metadata file
var storageFile = new StorageFile(
request.FileName,
bucketName,
objectKey,
request.ContentType,
request.FileSizeBytes,
request.UserId,
provider.ProviderType,
request.AccessLevel,
request.TenantId,
uploadResult.Checksum);
await _fileRepository.AddAsync(storageFile, cancellationToken);
// EN: Update quota / VI: Cập nhật quota
quota.AddUsage(request.FileSizeBytes);
_quotaRepository.Update(quota);
// EN: Save changes / VI: Lưu thay đổi
await _fileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("File uploaded successfully: {FileId}, {ObjectKey}",
storageFile.Id, objectKey);
return new UploadFileResult(true, storageFile.Id, objectKey, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading file for user {UserId}", request.UserId);
return new UploadFileResult(false, null, null, "An error occurred while uploading the file.");
}
}
private static string GenerateObjectKey(string userId, string fileName)
{
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd");
var uniqueId = Guid.NewGuid().ToString("N")[..8];
var safeFileName = SanitizeFileName(fileName);
return $"{userId}/{timestamp}/{uniqueId}_{safeFileName}";
}
private static string SanitizeFileName(string fileName)
{
var invalidChars = Path.GetInvalidFileNameChars();
return string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
}
}

View File

@@ -0,0 +1,67 @@
using StorageService.Domain.AggregatesModel.FileAggregate;
namespace StorageService.API.Application.Queries;
/// <summary>
/// EN: DTO for file information.
/// VI: DTO cho thông tin file.
/// </summary>
public record FileDto(
Guid Id,
string FileName,
string ContentType,
long FileSizeBytes,
string Provider,
string AccessLevel,
DateTime UploadedAt,
DateTime? LastAccessedAt);
/// <summary>
/// EN: Result for user files query.
/// VI: Kết quả query files của user.
/// </summary>
public record UserFilesResult(
IReadOnlyList<FileDto> Files,
int TotalCount);
/// <summary>
/// EN: DTO for user quota.
/// VI: DTO cho quota user.
/// </summary>
public record QuotaDto(
string UserId,
long MaxStorageBytes,
long UsedStorageBytes,
long RemainingStorageBytes,
int MaxFileCount,
int CurrentFileCount,
int RemainingFileCount,
double UsagePercentage,
string? QuotaTier);
/// <summary>
/// EN: Result for download URL query.
/// VI: Kết quả query download URL.
/// </summary>
public record DownloadUrlResult(
bool Success,
string? Url,
int? ExpiresInSeconds,
string? Error);
/// <summary>
/// EN: Mapper from domain entities to DTOs.
/// VI: Mapper từ domain entities sang DTOs.
/// </summary>
public static class FileDtoMapper
{
public static FileDto ToDto(this StorageFile file) => new(
file.Id,
file.FileName,
file.ContentType,
file.FileSizeBytes,
file.Provider.ToString(),
file.AccessLevel.ToString(),
file.UploadedAt,
file.LastAccessedAt);
}

View File

@@ -0,0 +1,36 @@
using MediatR;
namespace StorageService.API.Application.Queries;
/// <summary>
/// EN: Query to get file by ID.
/// VI: Query để lấy file theo ID.
/// </summary>
public record GetFileQuery(Guid FileId, string UserId) : IRequest<FileDto?>;
/// <summary>
/// EN: Query to get user files.
/// VI: Query để lấy files của user.
/// </summary>
public record GetUserFilesQuery(
string UserId,
int Skip = 0,
int Take = 20,
string? SearchTerm = null
) : IRequest<UserFilesResult>;
/// <summary>
/// EN: Query to get user quota.
/// VI: Query để lấy quota user.
/// </summary>
public record GetUserQuotaQuery(string UserId) : IRequest<QuotaDto?>;
/// <summary>
/// EN: Query to get pre-signed download URL.
/// VI: Query để lấy pre-signed download URL.
/// </summary>
public record GetDownloadUrlQuery(
Guid FileId,
string UserId,
int ExpirationSeconds = 3600
) : IRequest<DownloadUrlResult>;

View File

@@ -0,0 +1,159 @@
using MediatR;
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Domain.AggregatesModel.QuotaAggregate;
using StorageService.Infrastructure.Storage;
namespace StorageService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetFileQuery.
/// VI: Handler cho GetFileQuery.
/// </summary>
public class GetFileQueryHandler : IRequestHandler<GetFileQuery, FileDto?>
{
private readonly IFileRepository _fileRepository;
public GetFileQueryHandler(IFileRepository fileRepository)
{
_fileRepository = fileRepository;
}
public async Task<FileDto?> Handle(GetFileQuery request, CancellationToken cancellationToken)
{
var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken);
if (file == null || file.UserId != request.UserId)
return null;
return file.ToDto();
}
}
/// <summary>
/// EN: Handler for GetUserFilesQuery.
/// VI: Handler cho GetUserFilesQuery.
/// </summary>
public class GetUserFilesQueryHandler : IRequestHandler<GetUserFilesQuery, UserFilesResult>
{
private readonly IFileRepository _fileRepository;
public GetUserFilesQueryHandler(IFileRepository fileRepository)
{
_fileRepository = fileRepository;
}
public async Task<UserFilesResult> Handle(GetUserFilesQuery request, CancellationToken cancellationToken)
{
IEnumerable<StorageFile> files;
if (!string.IsNullOrWhiteSpace(request.SearchTerm))
{
files = await _fileRepository.SearchAsync(
request.UserId,
request.SearchTerm,
request.Skip,
request.Take,
cancellationToken);
}
else
{
files = await _fileRepository.GetByUserIdAsync(
request.UserId,
request.Skip,
request.Take,
cancellationToken);
}
var totalCount = await _fileRepository.GetFileCountByUserIdAsync(request.UserId, cancellationToken);
return new UserFilesResult(
files.Select(f => f.ToDto()).ToList(),
totalCount);
}
}
/// <summary>
/// EN: Handler for GetUserQuotaQuery.
/// VI: Handler cho GetUserQuotaQuery.
/// </summary>
public class GetUserQuotaQueryHandler : IRequestHandler<GetUserQuotaQuery, QuotaDto?>
{
private readonly IQuotaRepository _quotaRepository;
public GetUserQuotaQueryHandler(IQuotaRepository quotaRepository)
{
_quotaRepository = quotaRepository;
}
public async Task<QuotaDto?> Handle(GetUserQuotaQuery request, CancellationToken cancellationToken)
{
var quota = await _quotaRepository.GetOrCreateAsync(request.UserId, cancellationToken);
return new QuotaDto(
quota.UserId,
quota.MaxStorageBytes,
quota.UsedStorageBytes,
quota.RemainingStorageBytes,
quota.MaxFileCount,
quota.CurrentFileCount,
quota.RemainingFileCount,
quota.UsagePercentage,
quota.QuotaTier);
}
}
/// <summary>
/// EN: Handler for GetDownloadUrlQuery.
/// VI: Handler cho GetDownloadUrlQuery.
/// </summary>
public class GetDownloadUrlQueryHandler : IRequestHandler<GetDownloadUrlQuery, DownloadUrlResult>
{
private readonly IFileRepository _fileRepository;
private readonly IStorageProviderFactory _storageProviderFactory;
private readonly ILogger<GetDownloadUrlQueryHandler> _logger;
public GetDownloadUrlQueryHandler(
IFileRepository fileRepository,
IStorageProviderFactory storageProviderFactory,
ILogger<GetDownloadUrlQueryHandler> logger)
{
_fileRepository = fileRepository;
_storageProviderFactory = storageProviderFactory;
_logger = logger;
}
public async Task<DownloadUrlResult> Handle(GetDownloadUrlQuery request, CancellationToken cancellationToken)
{
try
{
var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken);
if (file == null)
return new DownloadUrlResult(false, null, null, "File not found.");
// EN: Check access / VI: Kiểm tra quyền truy cập
if (file.AccessLevel == FileAccessLevel.Private && file.UserId != request.UserId)
return new DownloadUrlResult(false, null, null, "Access denied.");
// EN: Mark as accessed / VI: Đánh dấu đã truy cập
file.MarkAccessed();
_fileRepository.Update(file);
await _fileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
// EN: Generate pre-signed URL / VI: Tạo pre-signed URL
var provider = _storageProviderFactory.GetProvider(file.Provider);
var url = await provider.GetPreSignedDownloadUrlAsync(
file.BucketName,
file.ObjectKey,
request.ExpirationSeconds,
cancellationToken);
return new DownloadUrlResult(true, url, request.ExpirationSeconds, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating download URL for file {FileId}", request.FileId);
return new DownloadUrlResult(false, null, null, "An error occurred.");
}
}
}

View File

@@ -0,0 +1,23 @@
using MediatR;
namespace StorageService.API.Application.Queries;
/// <summary>
/// EN: Query to get a Sample by ID.
/// VI: Query để lấy một Sample theo ID.
/// </summary>
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
/// <summary>
/// EN: Sample view model for API responses.
/// VI: Sample view model cho API responses.
/// </summary>
public record SampleViewModel(
Guid Id,
string Name,
string? Description,
string Status,
DateTime CreatedAt,
DateTime? UpdatedAt
);

View File

@@ -0,0 +1,39 @@
using MediatR;
using StorageService.Domain.AggregatesModel.SampleAggregate;
namespace StorageService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetSampleQuery.
/// VI: Handler cho GetSampleQuery.
/// </summary>
public class GetSampleQueryHandler : IRequestHandler<GetSampleQuery, SampleViewModel?>
{
private readonly ISampleRepository _sampleRepository;
public GetSampleQueryHandler(ISampleRepository sampleRepository)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
}
public async Task<SampleViewModel?> Handle(
GetSampleQuery request,
CancellationToken cancellationToken)
{
var sample = await _sampleRepository.GetAsync(request.SampleId);
if (sample is null)
{
return null;
}
return new SampleViewModel(
sample.Id,
sample.Name,
sample.Description,
sample.Status.Name,
sample.CreatedAt,
sample.UpdatedAt
);
}
}

View File

@@ -0,0 +1,9 @@
using MediatR;
namespace StorageService.API.Application.Queries;
/// <summary>
/// EN: Query to get all Samples.
/// VI: Query để lấy tất cả Samples.
/// </summary>
public record GetSamplesQuery : IRequest<IEnumerable<SampleViewModel>>;

View File

@@ -0,0 +1,34 @@
using MediatR;
using StorageService.Domain.AggregatesModel.SampleAggregate;
namespace StorageService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetSamplesQuery.
/// VI: Handler cho GetSamplesQuery.
/// </summary>
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, IEnumerable<SampleViewModel>>
{
private readonly ISampleRepository _sampleRepository;
public GetSamplesQueryHandler(ISampleRepository sampleRepository)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
}
public async Task<IEnumerable<SampleViewModel>> Handle(
GetSamplesQuery request,
CancellationToken cancellationToken)
{
var samples = await _sampleRepository.GetAllAsync();
return samples.Select(sample => new SampleViewModel(
sample.Id,
sample.Name,
sample.Description,
sample.Status.Name,
sample.CreatedAt,
sample.UpdatedAt
));
}
}

View File

@@ -0,0 +1,25 @@
using FluentValidation;
using StorageService.API.Application.Commands;
namespace StorageService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreateSampleCommand.
/// VI: Validator cho CreateSampleCommand.
/// </summary>
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
{
public CreateSampleCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Name is required / Tên là bắt buộc")
.MaximumLength(200)
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
RuleFor(x => x.Description)
.MaximumLength(1000)
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
.When(x => x.Description != null);
}
}

View File

@@ -0,0 +1,29 @@
using FluentValidation;
using StorageService.API.Application.Commands;
namespace StorageService.API.Application.Validations;
/// <summary>
/// EN: Validator for UpdateSampleCommand.
/// VI: Validator cho UpdateSampleCommand.
/// </summary>
public class UpdateSampleCommandValidator : AbstractValidator<UpdateSampleCommand>
{
public UpdateSampleCommandValidator()
{
RuleFor(x => x.SampleId)
.NotEmpty()
.WithMessage("Sample ID is required / ID sample là bắt buộc");
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Name is required / Tên là bắt buộc")
.MaximumLength(200)
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
RuleFor(x => x.Description)
.MaximumLength(1000)
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
.When(x => x.Description != null);
}
}

View File

@@ -0,0 +1,192 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StorageService.API.Application.Commands;
using StorageService.API.Application.Queries;
using Swashbuckle.AspNetCore.Annotations;
using System.Security.Claims;
namespace StorageService.API.Controllers;
/// <summary>
/// EN: Controller for file operations.
/// VI: Controller cho các thao tác file.
/// </summary>
[ApiController]
[Route("api/v1/files")]
[SwaggerTag("File Management - Upload, download, and manage files")]
public class FilesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<FilesController> _logger;
public FilesController(IMediator mediator, ILogger<FilesController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Upload a file.
/// VI: Upload file.
/// </summary>
[HttpPost("upload")]
[Authorize]
[RequestSizeLimit(104857600)] // 100MB
[SwaggerOperation(Summary = "Upload a file", Description = "Upload a file to storage")]
[SwaggerResponse(200, "File uploaded successfully")]
[SwaggerResponse(400, "Invalid request")]
[SwaggerResponse(401, "Unauthorized")]
public async Task<ActionResult<ApiResponse<UploadFileResult>>> Upload(
IFormFile file,
[FromQuery] string? accessLevel = "private",
CancellationToken cancellationToken = default)
{
var userId = GetUserId();
if (string.IsNullOrEmpty(userId))
return Unauthorized(new ApiResponse<UploadFileResult> { Success = false, Error = "User ID not found" });
if (file == null || file.Length == 0)
return BadRequest(new ApiResponse<UploadFileResult> { Success = false, Error = "No file provided" });
using var stream = file.OpenReadStream();
var fileAccessLevel = accessLevel?.ToLowerInvariant() switch
{
"public" => Domain.AggregatesModel.FileAggregate.FileAccessLevel.Public,
"shared" => Domain.AggregatesModel.FileAggregate.FileAccessLevel.Shared,
_ => Domain.AggregatesModel.FileAggregate.FileAccessLevel.Private
};
var command = new UploadFileCommand(
stream,
file.FileName,
file.ContentType,
file.Length,
userId,
null,
fileAccessLevel);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
return BadRequest(new ApiResponse<UploadFileResult> { Success = false, Error = result.Error });
return Ok(new ApiResponse<UploadFileResult> { Success = true, Data = result });
}
/// <summary>
/// EN: Get user files.
/// VI: Lấy danh sách files của user.
/// </summary>
[HttpGet]
[Authorize]
[SwaggerOperation(Summary = "Get user files", Description = "Get list of files uploaded by the current user")]
[SwaggerResponse(200, "Files retrieved successfully")]
public async Task<ActionResult<ApiResponse<UserFilesResult>>> GetFiles(
[FromQuery] int skip = 0,
[FromQuery] int take = 20,
[FromQuery] string? search = null,
CancellationToken cancellationToken = default)
{
var userId = GetUserId();
if (string.IsNullOrEmpty(userId))
return Unauthorized(new ApiResponse<UserFilesResult> { Success = false, Error = "User ID not found" });
var query = new GetUserFilesQuery(userId, skip, take, search);
var result = await _mediator.Send(query, cancellationToken);
return Ok(new ApiResponse<UserFilesResult> { Success = true, Data = result });
}
/// <summary>
/// EN: Get file by ID.
/// VI: Lấy thông tin file theo ID.
/// </summary>
[HttpGet("{fileId:guid}")]
[Authorize]
[SwaggerOperation(Summary = "Get file by ID", Description = "Get file metadata by ID")]
[SwaggerResponse(200, "File retrieved successfully")]
[SwaggerResponse(404, "File not found")]
public async Task<ActionResult<ApiResponse<FileDto>>> GetFile(
Guid fileId,
CancellationToken cancellationToken = default)
{
var userId = GetUserId();
if (string.IsNullOrEmpty(userId))
return Unauthorized(new ApiResponse<FileDto> { Success = false, Error = "User ID not found" });
var query = new GetFileQuery(fileId, userId);
var result = await _mediator.Send(query, cancellationToken);
if (result == null)
return NotFound(new ApiResponse<FileDto> { Success = false, Error = "File not found" });
return Ok(new ApiResponse<FileDto> { Success = true, Data = result });
}
/// <summary>
/// EN: Get pre-signed download URL.
/// VI: Lấy URL download có chữ ký.
/// </summary>
[HttpGet("{fileId:guid}/download-url")]
[Authorize]
[SwaggerOperation(Summary = "Get download URL", Description = "Get pre-signed URL for file download")]
[SwaggerResponse(200, "URL generated successfully")]
[SwaggerResponse(404, "File not found")]
public async Task<ActionResult<ApiResponse<DownloadUrlResult>>> GetDownloadUrl(
Guid fileId,
[FromQuery] int expirationSeconds = 3600,
CancellationToken cancellationToken = default)
{
var userId = GetUserId();
if (string.IsNullOrEmpty(userId))
return Unauthorized(new ApiResponse<DownloadUrlResult> { Success = false, Error = "User ID not found" });
var query = new GetDownloadUrlQuery(fileId, userId, expirationSeconds);
var result = await _mediator.Send(query, cancellationToken);
if (!result.Success)
return NotFound(new ApiResponse<DownloadUrlResult> { Success = false, Error = result.Error });
return Ok(new ApiResponse<DownloadUrlResult> { Success = true, Data = result });
}
/// <summary>
/// EN: Delete a file.
/// VI: Xóa file.
/// </summary>
[HttpDelete("{fileId:guid}")]
[Authorize]
[SwaggerOperation(Summary = "Delete file", Description = "Delete a file by ID")]
[SwaggerResponse(200, "File deleted successfully")]
[SwaggerResponse(404, "File not found")]
public async Task<ActionResult<ApiResponse<DeleteFileResult>>> Delete(
Guid fileId,
CancellationToken cancellationToken = default)
{
var userId = GetUserId();
if (string.IsNullOrEmpty(userId))
return Unauthorized(new ApiResponse<DeleteFileResult> { Success = false, Error = "User ID not found" });
var command = new DeleteFileCommand(fileId, userId);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
return NotFound(new ApiResponse<DeleteFileResult> { Success = false, Error = result.Error });
return Ok(new ApiResponse<DeleteFileResult> { Success = true, Data = result });
}
private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier);
}
/// <summary>
/// EN: Standard API response wrapper.
/// VI: Wrapper response API chuẩn.
/// </summary>
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string? Error { get; set; }
}

View File

@@ -0,0 +1,49 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StorageService.API.Application.Queries;
using Swashbuckle.AspNetCore.Annotations;
using System.Security.Claims;
namespace StorageService.API.Controllers;
/// <summary>
/// EN: Controller for storage quota operations.
/// VI: Controller cho các thao tác quota storage.
/// </summary>
[ApiController]
[Route("api/v1/quota")]
[SwaggerTag("Quota Management - View and manage storage quotas")]
public class QuotaController : ControllerBase
{
private readonly IMediator _mediator;
public QuotaController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// EN: Get current user's storage quota.
/// VI: Lấy quota storage của user hiện tại.
/// </summary>
[HttpGet]
[Authorize]
[SwaggerOperation(Summary = "Get user quota", Description = "Get storage quota for current user")]
[SwaggerResponse(200, "Quota retrieved successfully")]
[SwaggerResponse(401, "Unauthorized")]
public async Task<ActionResult<ApiResponse<QuotaDto>>> GetQuota(CancellationToken cancellationToken = default)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
return Unauthorized(new ApiResponse<QuotaDto> { Success = false, Error = "User ID not found" });
var query = new GetUserQuotaQuery(userId);
var result = await _mediator.Send(query, cancellationToken);
if (result == null)
return NotFound(new ApiResponse<QuotaDto> { Success = false, Error = "Quota not found" });
return Ok(new ApiResponse<QuotaDto> { Success = true, Data = result });
}
}

View File

@@ -0,0 +1,200 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using StorageService.API.Application.Commands;
using StorageService.API.Application.Queries;
namespace StorageService.API.Controllers;
/// <summary>
/// EN: Controller for Sample CRUD operations using CQRS pattern.
/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[Produces("application/json")]
public class SamplesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<SamplesController> _logger;
public SamplesController(IMediator mediator, ILogger<SamplesController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get all samples.
/// VI: Lấy tất cả samples.
/// </summary>
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<SampleViewModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetSamples()
{
var samples = await _mediator.Send(new GetSamplesQuery());
return Ok(new { success = true, data = samples });
}
/// <summary>
/// EN: Get a sample by ID.
/// VI: Lấy một sample theo ID.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <returns>EN: Sample details / VI: Chi tiết sample</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSample(Guid id)
{
var sample = await _mediator.Send(new GetSampleQuery(id));
if (sample is null)
{
return NotFound(new
{
success = false,
error = new
{
code = "SAMPLE_NOT_FOUND",
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
}
});
}
return Ok(new { success = true, data = sample });
}
/// <summary>
/// EN: Create a new sample.
/// VI: Tạo một sample mới.
/// </summary>
/// <param name="request">EN: Create request / VI: Request tạo</param>
/// <returns>EN: Created sample ID / VI: ID sample đã tạo</returns>
[HttpPost]
[ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateSample([FromBody] CreateSampleRequest request)
{
var command = new CreateSampleCommand(request.Name, request.Description);
var result = await _mediator.Send(command);
return CreatedAtAction(
nameof(GetSample),
new { id = result.Id },
new { success = true, data = result });
}
/// <summary>
/// EN: Update an existing sample.
/// VI: Cập nhật một sample đã tồn tại.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <param name="request">EN: Update request / VI: Request cập nhật</param>
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateSample(Guid id, [FromBody] UpdateSampleRequest request)
{
var command = new UpdateSampleCommand(id, request.Name, request.Description);
var result = await _mediator.Send(command);
if (!result)
{
return NotFound(new
{
success = false,
error = new
{
code = "SAMPLE_NOT_FOUND",
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
}
});
}
return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" });
}
/// <summary>
/// EN: Delete a sample.
/// VI: Xóa một sample.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteSample(Guid id)
{
var command = new DeleteSampleCommand(id);
var result = await _mediator.Send(command);
if (!result)
{
return NotFound(new
{
success = false,
error = new
{
code = "SAMPLE_NOT_FOUND",
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
}
});
}
return NoContent();
}
/// <summary>
/// EN: Change sample status.
/// VI: Thay đổi trạng thái sample.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <param name="request">EN: Status change request / VI: Request thay đổi trạng thái</param>
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
[HttpPatch("{id:guid}/status")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request)
{
var command = new ChangeSampleStatusCommand(id, request.Status);
var result = await _mediator.Send(command);
if (!result)
{
return BadRequest(new
{
success = false,
error = new
{
code = "STATUS_CHANGE_FAILED",
message = "Failed to change sample status / Thay đổi trạng thái sample thất bại"
}
});
}
return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" });
}
}
/// <summary>
/// EN: Request model for creating a sample.
/// VI: Model request để tạo sample.
/// </summary>
public record CreateSampleRequest(string Name, string? Description);
/// <summary>
/// EN: Request model for updating a sample.
/// VI: Model request để cập nhật sample.
/// </summary>
public record UpdateSampleRequest(string Name, string? Description);
/// <summary>
/// EN: Request model for changing sample status.
/// VI: Model request để thay đổi trạng thái sample.
/// </summary>
public record ChangeStatusRequest(string Status);

View File

@@ -0,0 +1,144 @@
using Asp.Versioning;
using FluentValidation;
using Hellang.Middleware.ProblemDetails;
using StorageService.API.Application.Behaviors;
using StorageService.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 StorageService API / Khởi động StorageService 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 / VI: Thêm Infrastructure services
builder.Services.AddInfrastructure(builder.Configuration);
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>));
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});
// EN: Add FluentValidation / VI: Thêm FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// 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 = "StorageService API",
Version = "v1",
Description = "StorageService microservice API / API microservice StorageService"
});
});
// 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", "StorageService API v1");
c.RoutePrefix = "swagger";
});
}
app.UseCors();
app.UseRouting();
// 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 { }

View File

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

View File

@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<AssemblyName>StorageService.API</AssemblyName>
<RootNamespace>StorageService.API</RootNamespace>
<Description>Web API layer with CQRS pattern for Storage Service</Description>
<UserSecretsId>storageservice-api</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<!-- EN: MediatR for CQRS / VI: MediatR cho CQRS -->
<PackageReference Include="MediatR" Version="12.4.1" />
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<!-- EN: API Versioning / VI: API Versioning -->
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<!-- EN: Health checks / VI: Health checks -->
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
<!-- EN: Problem Details (RFC 7807) / VI: Problem Details (RFC 7807) -->
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
<!-- EN: Serilog for structured logging / VI: Serilog cho structured logging -->
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StorageService.Domain\StorageService.Domain.csproj" />
<ProjectReference Include="..\StorageService.Infrastructure\StorageService.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,37 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5433;Database=storage_db;Username=postgres;Password=postgres"
},
"Storage": {
"Provider": "minio",
"DefaultBucket": "storage",
"PreSignedUrlExpirationSeconds": 3600,
"MaxFileSizeBytes": 104857600,
"MinIO": {
"Endpoint": "localhost:9000",
"AccessKey": "minioadmin",
"SecretKey": "minioadmin",
"UseSSL": false,
"Region": ""
},
"AliyunOSS": {
"Endpoint": "",
"AccessKeyId": "",
"AccessKeySecret": "",
"Region": ""
}
},
"IamService": {
"BaseUrl": "http://localhost:5001",
"ServiceName": "storage-service",
"TimeoutSeconds": 30
}
}

View File

@@ -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=myservice_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": "*"
}

View File

@@ -0,0 +1,34 @@
using MediatR;
namespace StorageService.Domain.AggregatesModel.FileAggregate;
/// <summary>
/// EN: Domain event raised when a file is uploaded.
/// VI: Domain event được phát khi file được upload.
/// </summary>
public record FileUploadedDomainEvent(
Guid FileId,
string FileName,
string UserId,
long FileSizeBytes
) : INotification;
/// <summary>
/// EN: Domain event raised when a file is deleted.
/// VI: Domain event được phát khi file bị xóa.
/// </summary>
public record FileDeletedDomainEvent(
Guid FileId,
string UserId,
long FileSizeBytes
) : INotification;
/// <summary>
/// EN: Domain event raised when user quota is updated.
/// VI: Domain event được phát khi quota user được cập nhật.
/// </summary>
public record UserQuotaUpdatedDomainEvent(
string UserId,
long UsedStorageBytes,
int FileCount
) : INotification;

View File

@@ -0,0 +1,67 @@
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.FileAggregate;
/// <summary>
/// EN: Repository interface for StorageFile aggregate.
/// VI: Interface repository cho StorageFile aggregate.
/// </summary>
public interface IFileRepository : IRepository<StorageFile>
{
/// <summary>
/// EN: Add a new file.
/// VI: Thêm file mới.
/// </summary>
Task<StorageFile> AddAsync(StorageFile file, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Update an existing file.
/// VI: Cập nhật file.
/// </summary>
void Update(StorageFile file);
/// <summary>
/// EN: Get file by ID.
/// VI: Lấy file theo ID.
/// </summary>
Task<StorageFile?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get file by object key.
/// VI: Lấy file theo object key.
/// </summary>
Task<StorageFile?> GetByObjectKeyAsync(string objectKey, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get files by user ID.
/// VI: Lấy files theo user ID.
/// </summary>
Task<IEnumerable<StorageFile>> GetByUserIdAsync(
string userId,
int skip = 0,
int take = 20,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get total size of files by user ID.
/// VI: Lấy tổng kích thước files theo user ID.
/// </summary>
Task<long> GetTotalSizeByUserIdAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get file count by user ID.
/// VI: Lấy số lượng files theo user ID.
/// </summary>
Task<int> GetFileCountByUserIdAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Search files by name.
/// VI: Tìm kiếm files theo tên.
/// </summary>
Task<IEnumerable<StorageFile>> SearchAsync(
string userId,
string? searchTerm,
int skip = 0,
int take = 20,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,30 @@
namespace StorageService.Domain.AggregatesModel.FileAggregate;
/// <summary>
/// EN: Storage provider enumeration.
/// VI: Enum định nghĩa các provider lưu trữ.
/// </summary>
public enum StorageProvider
{
/// <summary>EN: MinIO S3-compatible storage / VI: MinIO lưu trữ tương thích S3</summary>
MinIO = 1,
/// <summary>EN: Alibaba Cloud OSS / VI: Alibaba Cloud OSS</summary>
AliyunOSS = 2
}
/// <summary>
/// EN: File access level enumeration.
/// VI: Enum định nghĩa mức độ truy cập file.
/// </summary>
public enum FileAccessLevel
{
/// <summary>EN: Private - Only owner can access / VI: Riêng tư - Chỉ owner có thể truy cập</summary>
Private = 1,
/// <summary>EN: Public - Anyone can access / VI: Công khai - Bất kỳ ai có thể truy cập</summary>
Public = 2,
/// <summary>EN: Shared - Specific users can access / VI: Chia sẻ - Người dùng cụ thể có thể truy cập</summary>
Shared = 3
}

View File

@@ -0,0 +1,140 @@
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.FileAggregate;
/// <summary>
/// EN: Represents a file stored in the storage system.
/// VI: Đại diện cho file được lưu trữ trong hệ thống.
/// </summary>
public class StorageFile : Entity, IAggregateRoot
{
/// <summary>EN: Original file name / VI: Tên file gốc</summary>
public string FileName { get; private set; } = string.Empty;
/// <summary>EN: Bucket/container name / VI: Tên bucket/container</summary>
public string BucketName { get; private set; } = string.Empty;
/// <summary>EN: Unique key in storage / VI: Key duy nhất trong storage</summary>
public string ObjectKey { get; private set; } = string.Empty;
/// <summary>EN: MIME content type / VI: MIME content type</summary>
public string ContentType { get; private set; } = string.Empty;
/// <summary>EN: File size in bytes / VI: Kích thước file (bytes)</summary>
public long FileSizeBytes { get; private set; }
/// <summary>EN: Owner user ID / VI: ID người dùng sở hữu</summary>
public string UserId { get; private set; } = string.Empty;
/// <summary>EN: Tenant ID for multi-tenancy / VI: Tenant ID cho multi-tenancy</summary>
public string? TenantId { get; private set; }
/// <summary>EN: Storage provider used / VI: Provider lưu trữ được sử dụng</summary>
public StorageProvider Provider { get; private set; }
/// <summary>EN: Access level for the file / VI: Mức độ truy cập của file</summary>
public FileAccessLevel AccessLevel { get; private set; }
/// <summary>EN: Upload timestamp / VI: Thời gian upload</summary>
public DateTime UploadedAt { get; private set; }
/// <summary>EN: Last access timestamp / VI: Thời gian truy cập cuối</summary>
public DateTime? LastAccessedAt { get; private set; }
/// <summary>EN: Expiration timestamp (for temporary files) / VI: Thời gian hết hạn (cho file tạm)</summary>
public DateTime? ExpiresAt { get; private set; }
/// <summary>EN: File checksum (MD5/SHA256) / VI: Checksum file (MD5/SHA256)</summary>
public string? Checksum { get; private set; }
/// <summary>EN: Soft delete flag / VI: Cờ xóa mềm</summary>
public bool IsDeleted { get; private set; }
/// <summary>EN: Deleted timestamp / VI: Thời gian xóa</summary>
public DateTime? DeletedAt { get; private set; }
// EN: EF Core requires parameterless constructor / VI: EF Core cần constructor không tham số
protected StorageFile() { }
/// <summary>
/// EN: Create a new storage file.
/// VI: Tạo một storage file mới.
/// </summary>
public StorageFile(
string fileName,
string bucketName,
string objectKey,
string contentType,
long fileSizeBytes,
string userId,
StorageProvider provider,
FileAccessLevel accessLevel = FileAccessLevel.Private,
string? tenantId = null,
string? checksum = null)
{
Id = Guid.NewGuid();
FileName = fileName ?? throw new ArgumentNullException(nameof(fileName));
BucketName = bucketName ?? throw new ArgumentNullException(nameof(bucketName));
ObjectKey = objectKey ?? throw new ArgumentNullException(nameof(objectKey));
ContentType = contentType ?? "application/octet-stream";
FileSizeBytes = fileSizeBytes;
UserId = userId ?? throw new ArgumentNullException(nameof(userId));
Provider = provider;
AccessLevel = accessLevel;
TenantId = tenantId;
Checksum = checksum;
UploadedAt = DateTime.UtcNow;
IsDeleted = false;
// EN: Raise domain event / VI: Phát domain event
AddDomainEvent(new FileUploadedDomainEvent(Id, fileName, userId, fileSizeBytes));
}
/// <summary>
/// EN: Mark file as accessed.
/// VI: Đánh dấu file đã được truy cập.
/// </summary>
public void MarkAccessed()
{
LastAccessedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update access level.
/// VI: Cập nhật mức độ truy cập.
/// </summary>
public void UpdateAccessLevel(FileAccessLevel newLevel)
{
if (IsDeleted)
throw new InvalidOperationException("Cannot update deleted file");
AccessLevel = newLevel;
}
/// <summary>
/// EN: Soft delete the file.
/// VI: Xóa mềm file.
/// </summary>
public void Delete()
{
if (IsDeleted)
return;
IsDeleted = true;
DeletedAt = DateTime.UtcNow;
AddDomainEvent(new FileDeletedDomainEvent(Id, UserId, FileSizeBytes));
}
/// <summary>
/// EN: Set expiration time.
/// VI: Đặt thời gian hết hạn.
/// </summary>
public void SetExpiration(DateTime expiresAt)
{
if (expiresAt <= DateTime.UtcNow)
throw new ArgumentException("Expiration must be in the future", nameof(expiresAt));
ExpiresAt = expiresAt;
}
}

View File

@@ -0,0 +1,40 @@
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.QuotaAggregate;
/// <summary>
/// EN: Repository interface for UserStorageQuota aggregate.
/// VI: Interface repository cho UserStorageQuota aggregate.
/// </summary>
public interface IQuotaRepository : IRepository<UserStorageQuota>
{
/// <summary>
/// EN: Add a new quota.
/// VI: Thêm quota mới.
/// </summary>
Task<UserStorageQuota> AddAsync(UserStorageQuota quota, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Update an existing quota.
/// VI: Cập nhật quota.
/// </summary>
void Update(UserStorageQuota quota);
/// <summary>
/// EN: Get quota by ID.
/// VI: Lấy quota theo ID.
/// </summary>
Task<UserStorageQuota?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get quota by user ID.
/// VI: Lấy quota theo user ID.
/// </summary>
Task<UserStorageQuota?> GetByUserIdAsync(string userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get or create quota for user.
/// VI: Lấy hoặc tạo quota cho user.
/// </summary>
Task<UserStorageQuota> GetOrCreateAsync(string userId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,151 @@
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.QuotaAggregate;
/// <summary>
/// EN: Represents a user's storage quota and usage.
/// VI: Đại diện cho quota và usage lưu trữ của user.
/// </summary>
public class UserStorageQuota : Entity, IAggregateRoot
{
/// <summary>EN: User ID / VI: ID người dùng</summary>
public string UserId { get; private set; } = string.Empty;
/// <summary>EN: Maximum storage in bytes / VI: Dung lượng tối đa (bytes)</summary>
public long MaxStorageBytes { get; private set; }
/// <summary>EN: Currently used storage in bytes / VI: Dung lượng đã sử dụng (bytes)</summary>
public long UsedStorageBytes { get; private set; }
/// <summary>EN: Maximum number of files / VI: Số file tối đa</summary>
public int MaxFileCount { get; private set; }
/// <summary>EN: Current file count / VI: Số file hiện tại</summary>
public int CurrentFileCount { get; private set; }
/// <summary>EN: Quota tier/plan name / VI: Tên tier/plan quota</summary>
public string? QuotaTier { get; private set; }
/// <summary>EN: Last updated timestamp / VI: Thời gian cập nhật cuối</summary>
public DateTime LastUpdatedAt { get; private set; }
/// <summary>EN: Created timestamp / VI: Thời gian tạo</summary>
public DateTime CreatedAt { get; private set; }
// EN: EF Core requires parameterless constructor / VI: EF Core cần constructor không tham số
protected UserStorageQuota() { }
/// <summary>
/// EN: Create a new user storage quota.
/// VI: Tạo quota lưu trữ mới cho user.
/// </summary>
public UserStorageQuota(
string userId,
long maxStorageBytes = 1073741824, // 1GB default
int maxFileCount = 1000,
string? quotaTier = "free")
{
Id = Guid.NewGuid();
UserId = userId ?? throw new ArgumentNullException(nameof(userId));
MaxStorageBytes = maxStorageBytes;
MaxFileCount = maxFileCount;
QuotaTier = quotaTier;
UsedStorageBytes = 0;
CurrentFileCount = 0;
CreatedAt = DateTime.UtcNow;
LastUpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Get remaining storage in bytes.
/// VI: Lấy dung lượng còn lại (bytes).
/// </summary>
public long RemainingStorageBytes => Math.Max(0, MaxStorageBytes - UsedStorageBytes);
/// <summary>
/// EN: Get remaining file count.
/// VI: Lấy số file còn có thể upload.
/// </summary>
public int RemainingFileCount => Math.Max(0, MaxFileCount - CurrentFileCount);
/// <summary>
/// EN: Get usage percentage.
/// VI: Lấy phần trăm sử dụng.
/// </summary>
public double UsagePercentage => MaxStorageBytes > 0
? Math.Round((double)UsedStorageBytes / MaxStorageBytes * 100, 2)
: 0;
/// <summary>
/// EN: Check if user can upload file with given size.
/// VI: Kiểm tra user có thể upload file với kích thước cho trước.
/// </summary>
public bool CanUpload(long fileSizeBytes)
{
return UsedStorageBytes + fileSizeBytes <= MaxStorageBytes
&& CurrentFileCount < MaxFileCount;
}
/// <summary>
/// EN: Add storage usage after upload.
/// VI: Thêm usage sau khi upload.
/// </summary>
public void AddUsage(long fileSizeBytes, int fileCount = 1)
{
if (fileSizeBytes < 0)
throw new ArgumentException("File size cannot be negative", nameof(fileSizeBytes));
UsedStorageBytes += fileSizeBytes;
CurrentFileCount += fileCount;
LastUpdatedAt = DateTime.UtcNow;
AddDomainEvent(new UserQuotaUpdatedDomainEvent(UserId, UsedStorageBytes, CurrentFileCount));
}
/// <summary>
/// EN: Remove storage usage after delete.
/// VI: Giảm usage sau khi xóa.
/// </summary>
public void RemoveUsage(long fileSizeBytes, int fileCount = 1)
{
if (fileSizeBytes < 0)
throw new ArgumentException("File size cannot be negative", nameof(fileSizeBytes));
UsedStorageBytes = Math.Max(0, UsedStorageBytes - fileSizeBytes);
CurrentFileCount = Math.Max(0, CurrentFileCount - fileCount);
LastUpdatedAt = DateTime.UtcNow;
AddDomainEvent(new UserQuotaUpdatedDomainEvent(UserId, UsedStorageBytes, CurrentFileCount));
}
/// <summary>
/// EN: Update quota limits.
/// VI: Cập nhật giới hạn quota.
/// </summary>
public void UpdateLimits(long maxStorageBytes, int maxFileCount, string? quotaTier = null)
{
if (maxStorageBytes < UsedStorageBytes)
throw new InvalidOperationException($"Cannot set max storage below current usage ({UsedStorageBytes} bytes)");
if (maxFileCount < CurrentFileCount)
throw new InvalidOperationException($"Cannot set max file count below current count ({CurrentFileCount})");
MaxStorageBytes = maxStorageBytes;
MaxFileCount = maxFileCount;
if (quotaTier != null)
QuotaTier = quotaTier;
LastUpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Recalculate usage (for sync purposes).
/// VI: Tính lại usage (để sync).
/// </summary>
public void RecalculateUsage(long totalBytes, int totalFiles)
{
UsedStorageBytes = Math.Max(0, totalBytes);
CurrentFileCount = Math.Max(0, totalFiles);
LastUpdatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,61 @@
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Repository interface for Sample aggregate.
/// VI: Interface repository cho Sample aggregate.
/// </summary>
/// <remarks>
/// EN: Following repository pattern, this interface defines the contract
/// for data access operations on Sample aggregate.
/// VI: Theo pattern repository, interface này định nghĩa contract
/// cho các thao tác truy cập dữ liệu trên Sample aggregate.
/// </remarks>
public interface ISampleRepository : IRepository<Sample>
{
/// <summary>
/// EN: Get a sample by its ID.
/// VI: Lấy một sample theo ID.
/// </summary>
/// <param name="sampleId">EN: The sample ID / VI: ID của sample</param>
/// <returns>EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy</returns>
Task<Sample?> GetAsync(Guid sampleId);
/// <summary>
/// EN: Get all samples.
/// VI: Lấy tất cả samples.
/// </summary>
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
Task<IEnumerable<Sample>> GetAllAsync();
/// <summary>
/// EN: Add a new sample.
/// VI: Thêm một sample mới.
/// </summary>
/// <param name="sample">EN: The sample to add / VI: Sample cần thêm</param>
/// <returns>EN: The added sample / VI: Sample đã thêm</returns>
Sample Add(Sample sample);
/// <summary>
/// EN: Update an existing sample.
/// VI: Cập nhật một sample đã tồn tại.
/// </summary>
/// <param name="sample">EN: The sample to update / VI: Sample cần cập nhật</param>
void Update(Sample sample);
/// <summary>
/// EN: Delete a sample.
/// VI: Xóa một sample.
/// </summary>
/// <param name="sample">EN: The sample to delete / VI: Sample cần xóa</param>
void Delete(Sample sample);
/// <summary>
/// EN: Get samples by status.
/// VI: Lấy samples theo trạng thái.
/// </summary>
/// <param name="statusId">EN: The status ID / VI: ID trạng thái</param>
/// <returns>EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước</returns>
Task<IEnumerable<Sample>> GetByStatusAsync(int statusId);
}

View File

@@ -0,0 +1,158 @@
using StorageService.Domain.Events;
using StorageService.Domain.Exceptions;
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Sample aggregate root demonstrating DDD patterns.
/// VI: Sample aggregate root minh họa các pattern DDD.
/// </summary>
public class Sample : Entity, IAggregateRoot
{
// EN: Private fields for encapsulation
// VI: Fields private để đóng gói
private string _name = null!;
private string? _description;
private SampleStatus _status = null!;
private DateTime _createdAt;
private DateTime? _updatedAt;
/// <summary>
/// EN: Sample name (required).
/// VI: Tên sample (bắt buộc).
/// </summary>
public string Name => _name;
/// <summary>
/// EN: Optional description.
/// VI: Mô tả tùy chọn.
/// </summary>
public string? Description => _description;
/// <summary>
/// EN: Current status.
/// VI: Trạng thái hiện tại.
/// </summary>
public SampleStatus Status => _status;
/// <summary>
/// EN: Status ID for EF Core mapping.
/// VI: ID trạng thái cho EF Core mapping.
/// </summary>
public int StatusId { get; private set; }
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected Sample()
{
}
/// <summary>
/// EN: Create a new Sample with required information.
/// VI: Tạo một Sample mới với thông tin bắt buộc.
/// </summary>
/// <param name="name">EN: Sample name / VI: Tên sample</param>
/// <param name="description">EN: Optional description / VI: Mô tả tùy chọn</param>
public Sample(string name, string? description = null) : this()
{
if (string.IsNullOrWhiteSpace(name))
throw new SampleDomainException("Sample name cannot be empty");
Id = Guid.NewGuid();
_name = name;
_description = description;
_status = SampleStatus.Draft;
StatusId = SampleStatus.Draft.Id;
_createdAt = DateTime.UtcNow;
// EN: Add domain event for creation
// VI: Thêm domain event cho việc tạo
AddDomainEvent(new SampleCreatedDomainEvent(this));
}
/// <summary>
/// EN: Update sample information.
/// VI: Cập nhật thông tin sample.
/// </summary>
public void Update(string name, string? description)
{
if (string.IsNullOrWhiteSpace(name))
throw new SampleDomainException("Sample name cannot be empty");
if (_status == SampleStatus.Cancelled)
throw new SampleDomainException("Cannot update a cancelled sample");
_name = name;
_description = description;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Activate the sample.
/// VI: Kích hoạt sample.
/// </summary>
public void Activate()
{
if (_status != SampleStatus.Draft)
throw new SampleDomainException("Only draft samples can be activated");
var previousStatus = _status;
_status = SampleStatus.Active;
StatusId = SampleStatus.Active.Id;
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
}
/// <summary>
/// EN: Complete the sample.
/// VI: Hoàn thành sample.
/// </summary>
public void Complete()
{
if (_status != SampleStatus.Active)
throw new SampleDomainException("Only active samples can be completed");
var previousStatus = _status;
_status = SampleStatus.Completed;
StatusId = SampleStatus.Completed.Id;
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
}
/// <summary>
/// EN: Cancel the sample.
/// VI: Hủy sample.
/// </summary>
public void Cancel()
{
if (_status == SampleStatus.Completed)
throw new SampleDomainException("Cannot cancel a completed sample");
if (_status == SampleStatus.Cancelled)
throw new SampleDomainException("Sample is already cancelled");
var previousStatus = _status;
_status = SampleStatus.Cancelled;
StatusId = SampleStatus.Cancelled.Id;
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
}
}

View File

@@ -0,0 +1,77 @@
using StorageService.Domain.SeedWork;
namespace StorageService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Sample status enumeration following type-safe enum pattern.
/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
/// </summary>
public class SampleStatus : Enumeration
{
/// <summary>
/// EN: Draft status - initial state
/// VI: Trạng thái nháp - trạng thái ban đầu
/// </summary>
public static SampleStatus Draft = new(1, nameof(Draft));
/// <summary>
/// EN: Active status - ready for use
/// VI: Trạng thái hoạt động - sẵn sàng sử dụng
/// </summary>
public static SampleStatus Active = new(2, nameof(Active));
/// <summary>
/// EN: Completed status - finished processing
/// VI: Trạng thái hoàn thành - đã xử lý xong
/// </summary>
public static SampleStatus Completed = new(3, nameof(Completed));
/// <summary>
/// EN: Cancelled status - cancelled by user
/// VI: Trạng thái đã hủy - bị hủy bởi người dùng
/// </summary>
public static SampleStatus Cancelled = new(4, nameof(Cancelled));
public SampleStatus(int id, string name) : base(id, name)
{
}
/// <summary>
/// EN: Get all available statuses.
/// VI: Lấy tất cả các trạng thái có sẵn.
/// </summary>
public static IEnumerable<SampleStatus> List() => GetAll<SampleStatus>();
/// <summary>
/// EN: Parse status from name.
/// VI: Parse trạng thái từ tên.
/// </summary>
public static SampleStatus FromName(string name)
{
var status = List().SingleOrDefault(s =>
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
if (status is null)
{
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
}
return status;
}
/// <summary>
/// EN: Parse status from ID.
/// VI: Parse trạng thái từ ID.
/// </summary>
public static SampleStatus From(int id)
{
var status = List().SingleOrDefault(s => s.Id == id);
if (status is null)
{
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
}
return status;
}
}

View File

@@ -0,0 +1,22 @@
using MediatR;
using StorageService.Domain.AggregatesModel.SampleAggregate;
namespace StorageService.Domain.Events;
/// <summary>
/// EN: Domain event raised when a new Sample is created.
/// VI: Domain event được phát ra khi một Sample mới được tạo.
/// </summary>
public class SampleCreatedDomainEvent : INotification
{
/// <summary>
/// EN: The newly created sample.
/// VI: Sample mới được tạo.
/// </summary>
public Sample Sample { get; }
public SampleCreatedDomainEvent(Sample sample)
{
Sample = sample;
}
}

View File

@@ -0,0 +1,39 @@
using MediatR;
using StorageService.Domain.AggregatesModel.SampleAggregate;
namespace StorageService.Domain.Events;
/// <summary>
/// EN: Domain event raised when Sample status changes.
/// VI: Domain event được phát ra khi trạng thái Sample thay đổi.
/// </summary>
public class SampleStatusChangedDomainEvent : INotification
{
/// <summary>
/// EN: The sample ID.
/// VI: ID của sample.
/// </summary>
public Guid SampleId { get; }
/// <summary>
/// EN: Previous status before the change.
/// VI: Trạng thái trước khi thay đổi.
/// </summary>
public SampleStatus PreviousStatus { get; }
/// <summary>
/// EN: New status after the change.
/// VI: Trạng thái mới sau khi thay đổi.
/// </summary>
public SampleStatus NewStatus { get; }
public SampleStatusChangedDomainEvent(
Guid sampleId,
SampleStatus previousStatus,
SampleStatus newStatus)
{
SampleId = sampleId;
PreviousStatus = previousStatus;
NewStatus = newStatus;
}
}

View File

@@ -0,0 +1,21 @@
namespace StorageService.Domain.Exceptions;
/// <summary>
/// EN: Base exception for domain errors.
/// VI: Exception cơ sở cho các lỗi domain.
/// </summary>
public class DomainException : Exception
{
public DomainException()
{
}
public DomainException(string message) : base(message)
{
}
public DomainException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,21 @@
namespace StorageService.Domain.Exceptions;
/// <summary>
/// EN: Exception for Sample aggregate domain errors.
/// VI: Exception cho các lỗi domain của Sample aggregate.
/// </summary>
public class SampleDomainException : DomainException
{
public SampleDomainException()
{
}
public SampleDomainException(string message) : base(message)
{
}
public SampleDomainException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,102 @@
using MediatR;
namespace StorageService.Domain.SeedWork;
/// <summary>
/// EN: Base class for all domain entities.
/// VI: Lớp cơ sở cho tất cả các entity trong domain.
/// </summary>
public abstract class Entity
{
private int? _requestedHashCode;
private Guid _id;
private List<INotification> _domainEvents = new();
/// <summary>
/// EN: Unique identifier for the entity.
/// VI: Định danh duy nhất cho entity.
/// </summary>
public virtual Guid Id
{
get => _id;
protected set => _id = value;
}
/// <summary>
/// EN: Domain events raised by this entity.
/// VI: Các domain event được phát ra bởi entity này.
/// </summary>
public IReadOnlyCollection<INotification> DomainEvents => _domainEvents.AsReadOnly();
/// <summary>
/// EN: Add a domain event to be dispatched.
/// VI: Thêm một domain event để dispatch.
/// </summary>
public void AddDomainEvent(INotification eventItem)
{
_domainEvents.Add(eventItem);
}
/// <summary>
/// EN: Remove a domain event.
/// VI: Xóa một domain event.
/// </summary>
public void RemoveDomainEvent(INotification eventItem)
{
_domainEvents.Remove(eventItem);
}
/// <summary>
/// EN: Clear all domain events.
/// VI: Xóa tất cả domain events.
/// </summary>
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,95 @@
using System.Reflection;
namespace StorageService.Domain.SeedWork;
/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// 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ú.
/// </remarks>
public abstract class Enumeration : IComparable
{
/// <summary>
/// EN: The name of the enumeration value.
/// VI: Tên của giá trị enumeration.
/// </summary>
public string Name { get; private set; }
/// <summary>
/// EN: The unique identifier of the enumeration value.
/// VI: Định danh duy nhất của giá trị enumeration.
/// </summary>
public int Id { get; private set; }
protected Enumeration(int id, string name) => (Id, Name) = (id, name);
public override string ToString() => Name;
/// <summary>
/// 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.
/// </summary>
public static IEnumerable<T> GetAll<T>() where T : Enumeration =>
typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Select(f => f.GetValue(null))
.Cast<T>();
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();
/// <summary>
/// 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.
/// </summary>
public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue)
{
return Math.Abs(firstValue.Id - secondValue.Id);
}
/// <summary>
/// EN: Parse an integer ID to the corresponding enumeration value.
/// VI: Parse một ID integer thành giá trị enumeration tương ứng.
/// </summary>
public static T FromValue<T>(int value) where T : Enumeration
{
var matchingItem = Parse<T, int>(value, "value", item => item.Id == value);
return matchingItem;
}
/// <summary>
/// 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.
/// </summary>
public static T FromDisplayName<T>(string displayName) where T : Enumeration
{
var matchingItem = Parse<T, string>(displayName, "display name", item => item.Name == displayName);
return matchingItem;
}
private static T Parse<T, TValue>(TValue value, string description, Func<T, bool> predicate) where T : Enumeration
{
var matchingItem = GetAll<T>().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);
}

View File

@@ -0,0 +1,15 @@
namespace StorageService.Domain.SeedWork;
/// <summary>
/// EN: Marker interface for aggregate roots.
/// VI: Interface đánh dấu cho aggregate roots.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface IAggregateRoot
{
}

View File

@@ -0,0 +1,15 @@
namespace StorageService.Domain.SeedWork;
/// <summary>
/// EN: Generic repository interface for aggregate roots.
/// VI: Interface repository generic cho aggregate roots.
/// </summary>
/// <typeparam name="T">EN: The aggregate root type / VI: Kiểu aggregate root</typeparam>
public interface IRepository<T> where T : IAggregateRoot
{
/// <summary>
/// EN: The unit of work for this repository.
/// VI: Unit of work cho repository này.
/// </summary>
IUnitOfWork UnitOfWork { get; }
}

View File

@@ -0,0 +1,30 @@
namespace StorageService.Domain.SeedWork;
/// <summary>
/// EN: Unit of Work pattern interface.
/// VI: Interface cho Unit of Work pattern.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface IUnitOfWork : IDisposable
{
/// <summary>
/// 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.
/// </summary>
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
/// <returns>EN: Number of entities written / VI: Số entity đã ghi</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// EN: Save all changes and dispatch domain events.
/// VI: Lưu tất cả thay đổi và dispatch domain events.
/// </summary>
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
/// <returns>EN: True if successful / VI: True nếu thành công</returns>
Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,53 @@
namespace StorageService.Domain.SeedWork;
/// <summary>
/// EN: Base class for Value Objects following DDD patterns.
/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public abstract class ValueObject
{
/// <summary>
/// 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.
/// </summary>
protected abstract IEnumerable<object?> 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);
}
/// <summary>
/// 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.
/// </summary>
protected ValueObject GetCopy()
{
return (ValueObject)MemberwiseClone();
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>StorageService.Domain</AssemblyName>
<RootNamespace>StorageService.Domain</RootNamespace>
<Description>Domain layer containing core business logic and entities for Storage Service</Description>
</PropertyGroup>
<ItemGroup>
<!-- EN: MediatR for domain events / VI: MediatR cho domain events -->
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,69 @@
namespace StorageService.Infrastructure.Configuration;
/// <summary>
/// EN: Storage settings for MinIO and Aliyun OSS.
/// VI: Cấu hình storage cho MinIO và Aliyun OSS.
/// </summary>
public class StorageSettings
{
public const string SectionName = "Storage";
/// <summary>EN: Active storage provider (minio or aliyun) / VI: Provider storage đang dùng (minio hoặc aliyun)</summary>
public string Provider { get; set; } = "minio";
/// <summary>EN: Default bucket name / VI: Tên bucket mặc định</summary>
public string DefaultBucket { get; set; } = "storage";
/// <summary>EN: Pre-signed URL expiration in seconds / VI: Thời gian hết hạn pre-signed URL (giây)</summary>
public int PreSignedUrlExpirationSeconds { get; set; } = 3600;
/// <summary>EN: Maximum file size in bytes / VI: Kích thước file tối đa (bytes)</summary>
public long MaxFileSizeBytes { get; set; } = 104857600; // 100MB
/// <summary>EN: MinIO configuration / VI: Cấu hình MinIO</summary>
public MinioSettings MinIO { get; set; } = new();
/// <summary>EN: Aliyun OSS configuration / VI: Cấu hình Aliyun OSS</summary>
public AliyunOssSettings AliyunOSS { get; set; } = new();
}
/// <summary>
/// EN: MinIO-specific settings.
/// VI: Cấu hình riêng cho MinIO.
/// </summary>
public class MinioSettings
{
/// <summary>EN: MinIO endpoint (host:port) / VI: Endpoint MinIO (host:port)</summary>
public string Endpoint { get; set; } = "localhost:9000";
/// <summary>EN: Access key / VI: Access key</summary>
public string AccessKey { get; set; } = string.Empty;
/// <summary>EN: Secret key / VI: Secret key</summary>
public string SecretKey { get; set; } = string.Empty;
/// <summary>EN: Use SSL/HTTPS / VI: Sử dụng SSL/HTTPS</summary>
public bool UseSSL { get; set; } = false;
/// <summary>EN: Region (optional) / VI: Region (tùy chọn)</summary>
public string? Region { get; set; }
}
/// <summary>
/// EN: Aliyun OSS-specific settings.
/// VI: Cấu hình riêng cho Aliyun OSS.
/// </summary>
public class AliyunOssSettings
{
/// <summary>EN: OSS endpoint / VI: Endpoint OSS</summary>
public string Endpoint { get; set; } = string.Empty;
/// <summary>EN: Access key ID / VI: Access key ID</summary>
public string AccessKeyId { get; set; } = string.Empty;
/// <summary>EN: Access key secret / VI: Access key secret</summary>
public string AccessKeySecret { get; set; } = string.Empty;
/// <summary>EN: Region / VI: Region</summary>
public string Region { get; set; } = string.Empty;
}

Some files were not shown because too many files have changed in this diff Show More