Merge branch 'main' into feat/authorize
This commit is contained in:
commit
4d87c34aa0
32
.env.example
Normal file
32
.env.example
Normal file
@ -0,0 +1,32 @@
|
||||
# App Configuration
|
||||
APP_NAME="ULFlow Starter Kit"
|
||||
APP_VERSION="0.1.0"
|
||||
APP_ENVIRONMENT="development"
|
||||
APP_TIMEZONE="Asia/Ho_Chi_Minh"
|
||||
|
||||
# Logger Configuration
|
||||
LOG_LEVEL="info" # debug, info, warn, error
|
||||
|
||||
# Server Configuration
|
||||
SERVER_HOST="0.0.0.0"
|
||||
SERVER_PORT=3000
|
||||
SERVER_READ_TIMEOUT=15
|
||||
SERVER_WRITE_TIMEOUT=15
|
||||
SERVER_SHUTDOWN_TIMEOUT=30
|
||||
|
||||
# Database Configuration
|
||||
DB_DRIVER="postgres"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT=5432
|
||||
DB_USERNAME="postgres"
|
||||
DB_PASSWORD="your_password_here"
|
||||
DB_NAME="ulflow"
|
||||
DB_SSLMODE="disable"
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET="your-32-byte-base64-encoded-secret-key-here"
|
||||
JWT_ACCESS_TOKEN_EXPIRE=15 # in minutes
|
||||
JWT_REFRESH_TOKEN_EXPIRE=10080 # in minutes (7 days)
|
||||
JWT_ALGORITHM="HS256"
|
||||
JWT_ISSUER="ulflow-starter-kit"
|
||||
JWT_AUDIENCE="ulflow-web"
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"enable_database": {
|
||||
"enabled": false
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@ -99,12 +99,12 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
APP_VERSION="dev-${{ gitea.sha }}"
|
||||
go build -v -ldflags="-s -w -X main.version=${APP_VERSION}" -o ./bin/api ./cmd/api
|
||||
go build -v -ldflags="-s -w -X main.version=${APP_VERSION}" -o ./bin/app ./cmd/app
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: api-binary
|
||||
name: app-binary
|
||||
path: ./bin/api
|
||||
|
||||
- name: Notify on success
|
||||
|
||||
@ -29,10 +29,51 @@ database:
|
||||
max_idle_conns: 5
|
||||
conn_max_lifetime: 300
|
||||
migration_path: "migrations"
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
|
||||
# JWT Configuration
|
||||
jwt:
|
||||
# Generate a secure random secret key using: openssl rand -base64 32
|
||||
secret: "your-32-byte-base64-encoded-secret-key-here"
|
||||
# Token expiration time in minutes (1440 minutes = 24 hours)
|
||||
expiration: 1440
|
||||
secret: "ulflow2121_this_is_a_secure_key_for_jwt_signing"
|
||||
# Access Token expiration time in minutes (15 minutes)
|
||||
access_token_expire: 15
|
||||
# Refresh Token expiration time in minutes (7 days = 10080 minutes)
|
||||
refresh_token_expire: 10080
|
||||
# Algorithm for JWT signing (HS256, HS384, HS512, RS256, etc.)
|
||||
algorithm: "HS256"
|
||||
# Issuer for JWT tokens
|
||||
issuer: "ulflow-starter-kit"
|
||||
# Audience for JWT tokens
|
||||
audience: ["ulflow-web"]
|
||||
|
||||
# Security configurations
|
||||
security:
|
||||
# Rate limiting for authentication endpoints (requests per minute)
|
||||
rate_limit:
|
||||
login: 5
|
||||
register: 3
|
||||
refresh: 10
|
||||
# Password policy
|
||||
password:
|
||||
min_length: 8
|
||||
require_upper: true
|
||||
require_lower: true
|
||||
require_number: true
|
||||
require_special: true
|
||||
# Cookie settings
|
||||
cookie:
|
||||
secure: true
|
||||
http_only: true
|
||||
same_site: "Lax" # or "Strict" for more security
|
||||
domain: "" # Set your domain in production
|
||||
path: "/"
|
||||
# CORS settings
|
||||
cors:
|
||||
allowed_origins: ["*"] # Restrict in production
|
||||
allowed_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
allowed_headers: ["Origin", "Content-Type", "Accept", "Authorization"]
|
||||
exposed_headers: ["Content-Length", "X-Total-Count"]
|
||||
allow_credentials: true
|
||||
max_age: 300 # 5 minutes
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
89
configs/security.example.yaml
Normal file
89
configs/security.example.yaml
Normal file
@ -0,0 +1,89 @@
|
||||
# Cấu hình bảo mật cho ứng dụng
|
||||
|
||||
# Cấu hình CORS
|
||||
cors:
|
||||
# Danh sách các domain được phép truy cập (sử dụng "*" để cho phép tất cả)
|
||||
allowed_origins:
|
||||
- "https://example.com"
|
||||
- "https://api.example.com"
|
||||
|
||||
# Các phương thức HTTP được phép
|
||||
allowed_methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
|
||||
# Các header được phép
|
||||
allowed_headers:
|
||||
- Origin
|
||||
- Content-Type
|
||||
- Content-Length
|
||||
- Accept-Encoding
|
||||
- X-CSRF-Token
|
||||
- Authorization
|
||||
- X-Requested-With
|
||||
- X-Request-ID
|
||||
|
||||
# Các header được phép hiển thị
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- X-Total-Count
|
||||
|
||||
# Cho phép gửi credentials (cookie, authorization headers)
|
||||
allow_credentials: true
|
||||
|
||||
# Thời gian cache preflight request (ví dụ: 5m, 1h)
|
||||
max_age: 5m
|
||||
|
||||
# Bật chế độ debug
|
||||
debug: false
|
||||
|
||||
# Cấu hình Rate Limiting
|
||||
rate_limit:
|
||||
# Số request tối đa trong khoảng thời gian
|
||||
rate: 100
|
||||
|
||||
# Khoảng thời gian (ví dụ: 1m, 5m, 1h)
|
||||
window: 1m
|
||||
|
||||
# Danh sách các route được bỏ qua rate limiting
|
||||
excluded_routes:
|
||||
- "/health"
|
||||
- "/metrics"
|
||||
|
||||
# Cấu hình Security Headers
|
||||
headers:
|
||||
# Bật/tắt security headers
|
||||
enabled: true
|
||||
|
||||
# Chính sách bảo mật nội dung (Content Security Policy)
|
||||
content_security_policy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'"
|
||||
|
||||
# Chính sách bảo mật truy cập tài nguyên (Cross-Origin)
|
||||
cross_origin_resource_policy: "same-origin"
|
||||
cross_origin_opener_policy: "same-origin"
|
||||
cross_origin_embedder_policy: "require-corp"
|
||||
|
||||
# Chính sách tham chiếu (Referrer-Policy)
|
||||
referrer_policy: "no-referrer-when-downgrade"
|
||||
|
||||
# Chính sách sử dụng các tính năng trình duyệt (Feature-Policy)
|
||||
feature_policy: "geolocation 'none'; midi 'none'; notifications 'none'; push 'none'; sync-xhr 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; fullscreen 'none'; payment 'none'"
|
||||
|
||||
# Chính sách bảo vệ clickjacking (X-Frame-Options)
|
||||
frame_options: "DENY"
|
||||
|
||||
# Chính sách bảo vệ XSS (X-XSS-Protection)
|
||||
xss_protection: "1; mode=block"
|
||||
|
||||
# Chính sách MIME type sniffing (X-Content-Type-Options)
|
||||
content_type_options: "nosniff"
|
||||
|
||||
# Chính sách Strict-Transport-Security (HSTS)
|
||||
strict_transport_security: "max-age=31536000; includeSubDomains; preload"
|
||||
|
||||
# Chính sách Permissions-Policy (thay thế cho Feature-Policy)
|
||||
permissions_policy: "geolocation=(), microphone=(), camera=()"
|
||||
209
docs/AUTHENTICATION.md
Normal file
209
docs/AUTHENTICATION.md
Normal file
@ -0,0 +1,209 @@
|
||||
# Hệ thống Xác thực và Phân quyền
|
||||
|
||||
## Mục lục
|
||||
1. [Tổng quan](#tổng-quan)
|
||||
2. [Luồng xử lý](#luồng-xử-lý)
|
||||
- [Đăng nhập](#đăng-nhập)
|
||||
- [Làm mới token](#làm-mới-token)
|
||||
- [Đăng xuất](#đăng-xuất)
|
||||
3. [Các thành phần chính](#các-thành-phần-chính)
|
||||
- [Auth Middleware](#auth-middleware)
|
||||
- [Token Service](#token-service)
|
||||
- [Session Management](#session-management)
|
||||
4. [Bảo mật](#bảo-mật)
|
||||
5. [Tích hợp](#tích-hợp)
|
||||
|
||||
## Tổng quan
|
||||
|
||||
Hệ thống xác thực sử dụng JWT (JSON Web Tokens) với cơ chế refresh token để đảm bảo bảo mật. Mỗi phiên đăng nhập sẽ có:
|
||||
- Access Token: Có thời hạn ngắn (15-30 phút)
|
||||
- Refresh Token: Có thời hạn dài hơn (7-30 ngày)
|
||||
- Session ID: Định danh duy nhất cho mỗi phiên
|
||||
|
||||
## Luồng xử lý
|
||||
|
||||
### Đăng nhập
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant AuthController
|
||||
participant AuthService
|
||||
participant TokenService
|
||||
participant SessionStore
|
||||
|
||||
Client->>AuthController: POST /api/v1/auth/login
|
||||
AuthController->>AuthService: Authenticate(credentials)
|
||||
AuthService->>UserRepository: FindByEmail(email)
|
||||
AuthService->>PasswordUtil: CompareHashAndPassword()
|
||||
AuthService->>TokenService: GenerateTokens(userID, sessionID)
|
||||
TokenService-->>AuthService: tokens
|
||||
AuthService->>SessionStore: Create(session)
|
||||
AuthService-->>AuthController: authResponse
|
||||
AuthController-->>Client: {accessToken, refreshToken, user}
|
||||
```
|
||||
|
||||
### Làm mới Token
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant AuthController
|
||||
participant TokenService
|
||||
participant SessionStore
|
||||
|
||||
Client->>AuthController: POST /api/v1/auth/refresh
|
||||
AuthController->>TokenService: RefreshToken(refreshToken)
|
||||
TokenService->>SessionStore: Get(sessionID)
|
||||
SessionStore-->>TokenService: session
|
||||
TokenService->>TokenService: ValidateRefreshToken()
|
||||
TokenService->>SessionStore: UpdateLastUsed()
|
||||
TokenService-->>AuthController: newTokens
|
||||
AuthController-->>Client: {accessToken, refreshToken}
|
||||
```
|
||||
|
||||
### Đăng xuất
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant AuthController
|
||||
participant TokenService
|
||||
participant SessionStore
|
||||
|
||||
Client->>AuthController: POST /api/v1/auth/logout
|
||||
AuthController->>TokenService: ExtractTokenMetadata()
|
||||
TokenService-->>AuthController: tokenClaims
|
||||
AuthController->>SessionStore: Delete(sessionID)
|
||||
AuthController-->>Client: 200 OK
|
||||
```
|
||||
|
||||
## Các thành phần chính
|
||||
|
||||
### Auth Middleware
|
||||
|
||||
#### `Authenticate()`
|
||||
- **Mục đích**: Xác thực access token trong header Authorization
|
||||
- **Luồng xử lý**:
|
||||
1. Lấy token từ header
|
||||
2. Xác thực token
|
||||
3. Kiểm tra session trong store
|
||||
4. Lưu thông tin user vào context
|
||||
|
||||
#### `RequireRole(roles ...string)`
|
||||
- **Mục đích**: Kiểm tra quyền truy cập dựa trên vai trò
|
||||
- **Luồng xử lý**:
|
||||
1. Lấy thông tin user từ context
|
||||
2. Kiểm tra user có vai trò phù hợp không
|
||||
3. Trả về lỗi nếu không có quyền
|
||||
|
||||
### Token Service
|
||||
|
||||
#### `GenerateTokens(userID, sessionID)`
|
||||
- Tạo access token và refresh token
|
||||
- Lưu thông tin session
|
||||
- Trả về cặp token
|
||||
|
||||
#### `ValidateToken(token)`
|
||||
- Xác thực chữ ký token
|
||||
- Kiểm tra thời hạn
|
||||
- Trả về claims nếu hợp lệ
|
||||
|
||||
### Session Management
|
||||
|
||||
#### `CreateSession(session)`
|
||||
- Tạo session mới
|
||||
- Lưu vào Redis với TTL
|
||||
- Trả về session ID
|
||||
|
||||
#### `GetSession(sessionID)`
|
||||
- Lấy thông tin session từ Redis
|
||||
- Cập nhật thời gian truy cập cuối
|
||||
- Trả về session nếu tồn tại
|
||||
|
||||
## Bảo mật
|
||||
|
||||
1. **Token Storage**
|
||||
- Access Token: Lưu trong memory (không lưu localStorage)
|
||||
- Refresh Token: HttpOnly, Secure, SameSite=Strict cookie
|
||||
|
||||
2. **Token Rotation**
|
||||
- Mỗi lần refresh sẽ tạo cặp token mới
|
||||
- Vô hiệu hóa refresh token cũ
|
||||
|
||||
3. **Thu hồi token**
|
||||
- Đăng xuất sẽ xóa session
|
||||
- Có thể thu hồi tất cả session của user
|
||||
|
||||
## Tích hợp
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **Xử lý token**
|
||||
```javascript
|
||||
// Lưu token vào memory
|
||||
let accessToken = null;
|
||||
|
||||
// Hàm gọi API với token
|
||||
export const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Thêm interceptor để gắn token
|
||||
api.interceptors.request.use(config => {
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Xử lý lỗi 401
|
||||
export function setupResponseInterceptor(logout) {
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
async error => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const { accessToken: newToken } = await refreshToken();
|
||||
accessToken = newToken;
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (error) {
|
||||
logout();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
1. **Cấu hình**
|
||||
```yaml
|
||||
auth:
|
||||
access_token_expiry: 15m
|
||||
refresh_token_expiry: 7d
|
||||
jwt_secret: your-secret-key
|
||||
refresh_secret: your-refresh-secret
|
||||
```
|
||||
|
||||
2. **Sử dụng middleware**
|
||||
```go
|
||||
// Áp dụng auth middleware
|
||||
router.Use(authMiddleware.Authenticate())
|
||||
|
||||
// Route yêu cầu đăng nhập
|
||||
router.GET("/profile", userHandler.GetProfile)
|
||||
|
||||
// Route yêu cầu quyền admin
|
||||
router.GET("/admin", authMiddleware.RequireRole("admin"), adminHandler.Dashboard)
|
||||
```
|
||||
168
docs/review.md
168
docs/review.md
@ -1,116 +1,80 @@
|
||||
# Đánh giá file `main.go`: Điểm bất thường và Đề xuất cải thiện
|
||||
🚀 Cải Thiện Luồng Xác Thực Cho Project Starter-Kit
|
||||
Một Starter-kit chất lượng cần có hệ thống xác thực được xây dựng trên các nguyên tắc bảo mật và thực hành tốt nhất. Dưới đây là những cải thiện quan trọng:
|
||||
|
||||
Dựa trên việc xem xét file `main.go` và các log liên quan, chúng ta đã xác định một số điểm trong thiết kế có thể dẫn đến hành vi khởi động không ổn định và log khó hiểu.
|
||||
1. Bảo Mật Refresh Token (RT) Phía Client – Ưu Tiên Hàng Đầu
|
||||
Vấn đề cốt lõi: Lưu RT trong localStorage hoặc sessionStorage khiến chúng dễ bị tấn công XSS.
|
||||
|
||||
## I. Các điểm bất thường đã xác định
|
||||
Giải pháp cho Starter-kit:
|
||||
|
||||
1. **`HTTPService.Start()` trả về ngay lập tức và che giấu lỗi khởi động Server:**
|
||||
* **Mô tả:** Hàm `HTTPService.Start()` khởi chạy server HTTP (ví dụ: `s.server.Start()` của Gin) trong một goroutine riêng biệt và ngay lập tức trả về `nil` cho `lifecycleMgr`.
|
||||
```go
|
||||
// Trong HTTPService.Start()
|
||||
go func() {
|
||||
if err := s.server.Start(); err != nil {
|
||||
logger.WithError(err).Error("HTTP server error")
|
||||
}
|
||||
}()
|
||||
return nil // HTTPService.Start() trả về nil ngay
|
||||
```
|
||||
* **Hậu quả:**
|
||||
* `lifecycleMgr` nhận được `nil` và cho rằng `HTTPService` đã khởi động thành công, ngay cả khi `s.server.Start()` trong goroutine gặp lỗi nghiêm trọng (ví dụ: cổng đã được sử dụng, lỗi cấu hình server) ngay sau đó.
|
||||
* Lỗi thực sự của việc khởi động server HTTP không được truyền trở lại cho `lifecycleMgr`, khiến việc quản lý vòng đời và chẩn đoán lỗi trở nên khó khăn.
|
||||
Sử dụng HttpOnly Cookies cho Refresh Token:
|
||||
|
||||
2. **Log "Application stopped" có thể xuất hiện không đúng thứ tự hoặc quá sớm:**
|
||||
* **Mô tả:** Dòng log `logger.Info("Application stopped")` nằm ở cuối hàm `main()` và chỉ được thực thi sau khi `lifecycleMgr.Wait()` kết thúc.
|
||||
* **Hậu quả:**
|
||||
* Do `HTTPService.Start()` trả về ngay và goroutine của server có thể lỗi và thoát một cách âm thầm, `lifecycleMgr.Wait()` có thể kết thúc sớm hơn dự kiến.
|
||||
* Điều này dẫn đến "race condition" trong logging: log "Application stopped" có thể xuất hiện trước cả các log từ bên trong `s.server.Start()` (như "Starting HTTP server"), gây nhầm lẫn khi phân tích log.
|
||||
Bắt buộc: Starter-kit NÊN mặc định hoặc hướng dẫn rõ ràng việc sử dụng cookie HttpOnly để lưu RT. Điều này ngăn JavaScript phía client truy cập RT.
|
||||
|
||||
3. **`lifecycleMgr` không nhận biết được trạng thái lỗi thực sự của HTTP Server:**
|
||||
* **Mô tả:** Vì `HTTPService.Start()` không phản ánh lỗi thực sự của việc khởi động server, `lifecycleMgr` không có thông tin chính xác về việc liệu `HTTPService` có đang hoạt động hay không.
|
||||
* **Hậu quả:**
|
||||
* Ứng dụng có thể "chết yểu" ở lần thử khởi động đầu tiên mà không có lỗi rõ ràng từ `lifecycleMgr`.
|
||||
* Điều này có thể khiến các cơ chế bên ngoài (như Docker health checks hoặc trình giám sát tiến trình) phải khởi động lại container nhiều lần cho đến khi một lần khởi động ngẫu nhiên thành công (nếu lỗi mang tính tạm thời).
|
||||
Access Token (AT) có thể được lưu trong bộ nhớ JavaScript (an toàn hơn localStorage cho AT có đời sống ngắn) hoặc sessionStorage nếu cần thiết cho SPA.
|
||||
|
||||
## II. Đề xuất cải thiện
|
||||
Thiết lập cờ Secure và SameSite cho Cookie:
|
||||
|
||||
Mục tiêu chính của các đề xuất này là đảm bảo rằng trạng thái khởi động thực sự của các service (đặc biệt là HTTP server) được phản ánh chính xác cho `lifecycleMgr`, giúp việc quản lý vòng đời và gỡ lỗi hiệu quả hơn.
|
||||
Secure: Đảm bảo cookie chỉ được gửi qua HTTPS.
|
||||
|
||||
1. **Cải thiện `HTTPService.Start()` để phản ánh trạng thái khởi động thực tế:**
|
||||
SameSite=Strict (hoặc SameSite=Lax): Giúp chống lại tấn công CSRF. Starter-kit NÊN có cấu hình này.
|
||||
|
||||
* **Phương án 1: Biến `HTTPService.Start()` thành hàm Blocking (Ưu tiên nếu phù hợp):**
|
||||
* Nếu `s.server.Start()` (ví dụ: `gin.Engine.Run()`) là một hàm blocking, hãy để `HTTPService.Start()` gọi trực tiếp nó mà không cần goroutine riêng.
|
||||
* Nếu `s.server.Start()` trả về lỗi, `HTTPService.Start()` cũng phải trả về lỗi đó.
|
||||
```go
|
||||
// Đề xuất cho HTTPService.Start()
|
||||
func (s *HTTPService) Start() error {
|
||||
logger.Infof("Attempting to start %s on %s:%d...", s.Name(), s.cfg.Server.Host, s.cfg.Server.Port)
|
||||
// Giả sử s.server.Start() là blocking và trả về lỗi nếu không khởi động được
|
||||
if err := s.server.Start(); err != nil {
|
||||
// Lỗi này sẽ được lifecycleMgr bắt và xử lý
|
||||
return fmt.Errorf("failed to start %s: %w", s.Name(), err)
|
||||
}
|
||||
// Nếu s.server.Start() trả về mà không lỗi (thường chỉ khi shutdown),
|
||||
// điều này có thể không bao giờ đạt tới nếu nó block mãi mãi.
|
||||
// Hoặc nếu nó được thiết kế để trả về nil khi shutdown thành công từ bên ngoài.
|
||||
return nil
|
||||
}
|
||||
```
|
||||
* `lifecycleMgr` có thể sẽ cần tự quản lý việc chạy các hàm `Start()` blocking này trong các goroutine riêng của nó.
|
||||
2. Quản Lý Refresh Token Phía Server – Đảm Bảo An Toàn
|
||||
Thực hành tốt đã có: Refresh Token Rotation (xoay vòng RT khi sử dụng) là rất tốt.
|
||||
|
||||
* **Phương án 2: Sử dụng Channel để giao tiếp trạng thái từ Goroutine:**
|
||||
* Nếu bắt buộc phải giữ `s.server.Start()` trong goroutine bên trong `HTTPService.Start()`, hãy sử dụng một channel để goroutine đó báo cáo lại trạng thái (khởi động thành công hoặc lỗi) cho `HTTPService.Start()`.
|
||||
* `HTTPService.Start()` sẽ đợi tín hiệu này và trả về lỗi tương ứng nếu có.
|
||||
```go
|
||||
// Đề xuất cho HTTPService.Start() với channel
|
||||
func (s *HTTPService) Start() error {
|
||||
logger.Infof("Attempting to start %s on %s:%d...", s.Name(), s.cfg.Server.Host, s.cfg.Server.Port)
|
||||
errChan := make(chan error, 1) // Buffered channel
|
||||
Cải thiện cho Starter-kit:
|
||||
|
||||
go func() {
|
||||
// Gửi lỗi (hoặc nil nếu thành công) vào channel
|
||||
// Cần đảm bảo s.server.Start() hoặc logic bao quanh nó có thể báo hiệu thành công sớm
|
||||
// Ví dụ, nếu server có một callback "onListenSuccess" hoặc tương tự
|
||||
err := s.server.Start() // Giả sử hàm này block cho đến khi có lỗi hoặc shutdown
|
||||
if err != nil && err != stdHttp.ErrServerClosed { // stdHttp từ "net/http"
|
||||
logger.WithError(err).Errorf("Error running %s in goroutine", s.Name())
|
||||
errChan <- fmt.Errorf("%s run error: %w", s.Name(), err)
|
||||
return
|
||||
}
|
||||
logger.Infof("%s goroutine finished cleanly.", s.Name())
|
||||
errChan <- nil // Báo hiệu kết thúc không lỗi (hoặc shutdown bình thường)
|
||||
}()
|
||||
Vô hiệu hóa RT cũ NGAY LẬP TỨC khi xoay vòng: Đảm bảo RT đã sử dụng không còn giá trị.
|
||||
|
||||
// Đợi tín hiệu từ goroutine hoặc timeout
|
||||
// Cần một cách để goroutine báo "đã sẵn sàng" thay vì chỉ đợi lỗi/kết thúc
|
||||
// Ví dụ, server có thể gửi nil vào errChan ngay khi lắng nghe thành công.
|
||||
// Đoạn code dưới đây là một ví dụ đơn giản hóa việc chờ lỗi khởi động ban đầu.
|
||||
// Một giải pháp tốt hơn là server có cơ chế báo "ready".
|
||||
select {
|
||||
case err := <-errChan:
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start %s (from channel): %w", s.Name(), err)
|
||||
}
|
||||
logger.Infof("%s reported successful start or clean exit via channel.", s.Name())
|
||||
return nil // Thành công hoặc goroutine đã kết thúc không lỗi nghiêm trọng ban đầu
|
||||
case <-time.After(10 * time.Second): // Timeout nếu không có tín hiệu sớm
|
||||
// Giả định rằng nếu không có lỗi ngay, server đang chạy.
|
||||
// Đây là một giả định cần được kiểm chứng cẩn thận với hành vi của s.server.Start()
|
||||
logger.Warnf("Timeout waiting for %s to signal start/error, assuming it's running or will manage its own lifecycle.", s.Name())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
```
|
||||
**Lưu ý quan trọng cho Phương án 2:** Cần có một cơ chế rõ ràng để goroutine của server báo hiệu rằng nó đã **khởi động và lắng nghe thành công** (ví dụ: gửi `nil` vào `errChan` ngay khi `ListenAnServe` bắt đầu thành công), chứ không chỉ đợi đến khi có lỗi hoặc server dừng hẳn.
|
||||
Thu hồi RT khi Logout: Endpoint /api/v1/auth/logout PHẢI xóa hoặc đánh dấu RT là đã thu hồi trong cơ sở dữ liệu. Chỉ xóa ở client là không đủ.
|
||||
|
||||
2. **Đảm bảo `lifecycleMgr` xử lý lỗi khởi động Service một cách nghiêm ngặt:**
|
||||
* Sau khi `HTTPService.Start()` (và các service khác) được sửa để trả về lỗi một cách chính xác, hãy đảm bảo rằng `lifecycleMgr.Start()` sẽ dừng toàn bộ quá trình nếu bất kỳ service nào không khởi động được.
|
||||
```go
|
||||
// Trong hàm main()
|
||||
if err := lifecycleMgr.Start(); err != nil {
|
||||
// Sử dụng Fatal để log lỗi và thoát ứng dụng ngay lập tức
|
||||
logger.WithError(err).Fatalf("Critical failure: One or more services failed to start. Application shutting down.")
|
||||
}
|
||||
```
|
||||
Điều này sẽ ngăn ứng dụng chạy trong trạng thái không ổn định và cung cấp thông tin lỗi rõ ràng ngay khi khởi động.
|
||||
(Khuyến nghị cho Starter-kit nâng cao): Cân nhắc cơ chế phát hiện việc sử dụng RT đã bị đánh cắp (ví dụ: nếu một RT cũ được dùng lại sau khi đã xoay vòng, hãy thu hồi tất cả RT của user đó).
|
||||
|
||||
Bằng cách áp dụng những cải tiến này, bạn sẽ có một hệ thống khởi động mạnh mẽ hơn, dễ chẩn đoán lỗi hơn và các thông điệp log sẽ phản ánh chính xác hơn những gì đang thực sự xảy ra trong ứng dụng.
|
||||
3. Tăng Cường Quy Trình Đăng Ký – Nền Tảng Người Dùng
|
||||
Cải thiện cho Starter-kit:
|
||||
|
||||
Chính Sách Mật Khẩu Tối Thiểu:
|
||||
|
||||
Yêu cầu độ dài mật khẩu tối thiểu (ví dụ: 8 hoặc 10 ký tự). Starter-kit NÊN có điều này.
|
||||
|
||||
(Tùy chọn): Khuyến khích hoặc yêu cầu kết hợp chữ hoa, chữ thường, số, ký tự đặc biệt.
|
||||
|
||||
Xác Thực Email (Khuyến Nghị Mạnh Mẽ):
|
||||
|
||||
Starter-kit NÊN bao gồm module hoặc hướng dẫn tích hợp quy trình gửi email xác thực để kích hoạt tài khoản. Điều này giúp đảm bảo email hợp lệ và là kênh liên lạc quan trọng.
|
||||
|
||||
4. Bảo Vệ Chống Tấn Công Đăng Nhập – Lớp Phòng Thủ Cơ Bản
|
||||
Cải thiện cho Starter-kit:
|
||||
|
||||
Rate Limiting cho Endpoint Đăng Nhập: Áp dụng giới hạn số lần thử đăng nhập thất bại (/api/v1/auth/login) dựa trên IP hoặc username/email.
|
||||
|
||||
Thông Báo Lỗi Chung Chung: Tránh các thông báo lỗi tiết lộ thông tin (ví dụ: "Username không tồn tại" hoặc "Sai mật khẩu"). Thay vào đó, sử dụng thông báo chung như "Tên đăng nhập hoặc mật khẩu không chính xác."
|
||||
|
||||
5. Thực Hành Tốt Nhất với JWT – Cốt Lõi Của Xác Thực
|
||||
Cải thiện cho Starter-kit:
|
||||
|
||||
Quản Lý Secret Key An Toàn:
|
||||
|
||||
Hướng dẫn lưu trữ JWT secret key trong biến môi trường (environment variables).
|
||||
|
||||
Tuyệt đối KHÔNG hardcode secret key trong mã nguồn.
|
||||
|
||||
Sử Dụng Thuật Toán Ký Mạnh:
|
||||
|
||||
Mặc định sử dụng thuật toán đối xứng mạnh như HS256.
|
||||
|
||||
Khuyến nghị và cung cấp tùy chọn cho thuật toán bất đối xứng như RS256 (yêu cầu quản lý cặp public/private key) cho các hệ thống phức tạp hơn.
|
||||
|
||||
Giữ Payload của Access Token Nhỏ Gọn:
|
||||
|
||||
Chỉ chứa thông tin cần thiết nhất (ví dụ: userId, roles).
|
||||
|
||||
Cân nhắc thêm iss (issuer) và aud (audience) để tăng cường xác minh token.
|
||||
|
||||
6. Xử Lý Lỗi và Ghi Log (Logging) An Toàn
|
||||
Cải thiện cho Starter-kit:
|
||||
|
||||
Không Ghi Log Thông Tin Nhạy Cảm: Tuyệt đối KHÔNG ghi log Access Token, Refresh Token, hoặc mật khẩu dưới bất kỳ hình thức nào.
|
||||
|
||||
Ghi Log Sự Kiện An Ninh: Hướng dẫn hoặc cung cấp cơ chế ghi log các sự kiện quan trọng (đăng nhập thành công/thất bại, yêu cầu làm mới token, thay đổi mật khẩu) một cách an toàn, không kèm dữ liệu nhạy cảm, để phục vụ việc giám sát và điều tra.
|
||||
|
||||
Bằng cách tích hợp những cải tiến này, Starter-kit của bạn sẽ cung cấp một điểm khởi đầu vững chắc và an toàn hơn cho các nhà phát triển.
|
||||
32
docs/session_20240524.md
Normal file
32
docs/session_20240524.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Tóm tắt phiên làm việc - 24/05/2025
|
||||
|
||||
## Các file đang mở
|
||||
1. `docs/roadmap.md` - Đang xem mục tiêu phát triển
|
||||
2. `configs/config.yaml` - File cấu hình ứng dụng
|
||||
3. `docs/review.md` - Đang xem phần đánh giá code
|
||||
|
||||
## Các thay đổi chính trong phiên
|
||||
|
||||
### 1. Cập nhật Roadmap
|
||||
- Đánh dấu hoàn thành các mục JWT Authentication
|
||||
- Cập nhật chi tiết Giai đoạn 2 (Bảo mật và xác thực)
|
||||
- Thêm timeline chi tiết cho từng tuần
|
||||
|
||||
### 2. Giải thích luồng xác thực
|
||||
- Đã giải thích chi tiết về luồng JWT authentication
|
||||
- Mô tả các endpoint chính và cách hoạt động
|
||||
- Giải thích về bảo mật token và xử lý lỗi
|
||||
|
||||
### 3. Các lệnh đã sử dụng
|
||||
- `/heyy` - Thảo luận về các bước tiếp theo
|
||||
- `/yys` - Thử lưu trạng thái (không khả dụng)
|
||||
|
||||
## Công việc đang thực hiện
|
||||
- Đang xem xét phần đánh giá code liên quan đến xử lý lỗi khởi động service
|
||||
|
||||
## Ghi chú
|
||||
- Cần hoàn thiện phần Health Check API
|
||||
- Cần triển khai API rate limiting và security headers
|
||||
|
||||
---
|
||||
*Tự động tạo lúc: 2025-05-24 12:26*
|
||||
6
go.mod
6
go.mod
@ -5,10 +5,12 @@ go 1.23.6
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/viper v1.17.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
go.uber.org/multierr v1.11.0
|
||||
gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
@ -58,7 +60,6 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
||||
@ -68,6 +69,5 @@ require (
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
6
go.sum
6
go.sum
@ -148,8 +148,6 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe
|
||||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
@ -171,6 +169,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
@ -296,7 +296,6 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125130003-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
@ -409,7 +408,6 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20130007135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
|
||||
@ -6,9 +6,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
envFile = "./.env"
|
||||
)
|
||||
|
||||
// ConfigLoader định nghĩa interface để load cấu hình
|
||||
type ConfigLoader interface {
|
||||
Load() (*Config, error)
|
||||
@ -32,88 +37,95 @@ func NewConfigLoader() ConfigLoader {
|
||||
}
|
||||
}
|
||||
|
||||
// Load đọc cấu hình từ file và biến môi trường
|
||||
func (l *ViperConfigLoader) Load() (*Config, error) {
|
||||
// Khởi tạo viper
|
||||
v := viper.New()
|
||||
|
||||
// Thiết lập tên config và loại
|
||||
v.SetConfigName(l.configName)
|
||||
v.SetConfigType(l.configType)
|
||||
|
||||
// Thêm các paths để tìm config
|
||||
// Thêm các đường dẫn tìm kiếm file cấu hình
|
||||
for _, path := range l.configPaths {
|
||||
v.AddConfigPath(path)
|
||||
}
|
||||
|
||||
// Tự động đọc biến môi trường
|
||||
// 1. Đọc file cấu hình chính (bắt buộc)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w (searched in: %v)", err, l.configPaths)
|
||||
}
|
||||
|
||||
// 2. Đọc từ file .env nếu tồn tại (tùy chọn)
|
||||
if err := l.loadEnvFile(v); err != nil {
|
||||
return nil, fmt.Errorf("error loading .env file: %w", err)
|
||||
}
|
||||
|
||||
// 3. Cấu hình đọc biến môi trường (có thể ghi đè các giá trị từ file)
|
||||
v.AutomaticEnv()
|
||||
v.SetEnvPrefix(l.envPrefix)
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
||||
|
||||
// Thiết lập giá trị mặc định
|
||||
// 4. Đặt các giá trị mặc định tối thiểu
|
||||
setDefaultValues(v)
|
||||
|
||||
// Đọc cấu hình
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
// Chỉ cảnh báo nếu không tìm thấy file, không gây lỗi
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("error reading config file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set default logger level
|
||||
if os.Getenv("LOG_LEVEL") == "" {
|
||||
if err := os.Setenv("LOG_LEVEL", "info"); err != nil {
|
||||
return nil, fmt.Errorf("failed to set LOG_LEVEL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Bind cấu hình vào struct
|
||||
var config Config
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("unable to decode config into struct: %v", err)
|
||||
return nil, fmt.Errorf("unable to decode config into struct: %w", err)
|
||||
}
|
||||
|
||||
// Validate cấu hình
|
||||
if err := validateConfig(&config); err != nil {
|
||||
return nil, fmt.Errorf("config validation error: %v", err)
|
||||
return nil, fmt.Errorf("config validation error: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// setDefaultValues thiết lập các giá trị mặc định cho config
|
||||
func setDefaultValues(v *viper.Viper) {
|
||||
// App defaults
|
||||
v.SetDefault("app.name", "GoStarter")
|
||||
v.SetDefault("app.version", "0.1.0")
|
||||
v.SetDefault("app.environment", "development")
|
||||
v.SetDefault("app.log_level", "info")
|
||||
v.SetDefault("app.timezone", "UTC")
|
||||
// loadEnvFile đọc và xử lý file .env
|
||||
func (l *ViperConfigLoader) loadEnvFile(v *viper.Viper) error {
|
||||
if _, err := os.Stat(envFile); os.IsNotExist(err) {
|
||||
return nil // Không có file .env cũng không phải lỗi
|
||||
}
|
||||
|
||||
// Server defaults
|
||||
v.SetDefault("server.host", "0.0.0.0")
|
||||
v.SetDefault("server.port", 3000)
|
||||
v.SetDefault("server.read_timeout", 15) // seconds
|
||||
v.SetDefault("server.write_timeout", 15) // seconds
|
||||
v.SetDefault("server.shutdown_timeout", 30) // seconds
|
||||
v.SetDefault("server.trusted_proxies", []string{})
|
||||
v.SetDefault("server.allow_origins", []string{"*"})
|
||||
envMap, err := godotenv.Read(envFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing .env file: %w", err)
|
||||
}
|
||||
|
||||
// Database defaults
|
||||
v.SetDefault("database.driver", "postgres")
|
||||
v.SetDefault("database.host", "localhost")
|
||||
v.SetDefault("database.port", 5432)
|
||||
v.SetDefault("database.max_open_conns", 25)
|
||||
v.SetDefault("database.max_idle_conns", 5)
|
||||
v.SetDefault("database.conn_max_lifetime", 300) // seconds
|
||||
v.SetDefault("database.ssl_mode", "disable")
|
||||
v.SetDefault("database.migration_path", "migrations")
|
||||
for key, val := range envMap {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Chuyển đổi key từ DB_PASSWORD thành database.password
|
||||
if parts := strings.SplitN(key, "_", 2); len(parts) == 2 {
|
||||
prefix := strings.ToLower(parts[0])
|
||||
suffix := strings.ReplaceAll(parts[1], "_", ".")
|
||||
v.Set(fmt.Sprintf("%s.%s", prefix, suffix), val)
|
||||
}
|
||||
|
||||
// Lưu giá trị gốc cho tương thích ngược
|
||||
v.Set(key, val)
|
||||
if err := os.Setenv(key, val); err != nil {
|
||||
return fmt.Errorf("failed to set environment variable %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfig xác thực cấu hình
|
||||
// setDefaultValues thiết lập các giá trị mặc định tối thiểu cần thiết
|
||||
// Lưu ý: Hầu hết các giá trị mặc định nên được định nghĩa trong file config.yaml
|
||||
func setDefaultValues(v *viper.Viper) {
|
||||
// Chỉ đặt các giá trị mặc định thực sự cần thiết ở đây
|
||||
// Các giá trị khác sẽ được lấy từ file config.yaml bắt buộc
|
||||
v.SetDefault("app.environment", "development")
|
||||
v.SetDefault("log_level", "info")
|
||||
}
|
||||
|
||||
// validateConfig xác thực cấu hình sử dụng thẻ validate
|
||||
func validateConfig(config *Config) error {
|
||||
validate := validator.New()
|
||||
return validate.Struct(config)
|
||||
if err := validate.Struct(config); err != nil {
|
||||
return fmt.Errorf("config validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -2,54 +2,57 @@ package config
|
||||
|
||||
// AppConfig chứa thông tin cấu hình của ứng dụng
|
||||
type AppConfig struct {
|
||||
Name string `mapstructure:"name" validate:"required"`
|
||||
Version string `mapstructure:"version" validate:"required"`
|
||||
Environment string `mapstructure:"environment" validate:"required,oneof=development staging production"`
|
||||
Timezone string `mapstructure:"timezone" validate:"required"`
|
||||
Name string `mapstructure:"name" validate:"required"`
|
||||
Version string `mapstructure:"version" validate:"required"`
|
||||
Environment string `mapstructure:"environment" validate:"required,oneof=development staging production"`
|
||||
Timezone string `mapstructure:"timezone" validate:"required"`
|
||||
}
|
||||
|
||||
// ServerConfig chứa thông tin cấu hình server
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host" validate:"required"`
|
||||
Port int `mapstructure:"port" validate:"required,min=1,max=65535"`
|
||||
ReadTimeout int `mapstructure:"read_timeout" validate:"required,min=1"`
|
||||
WriteTimeout int `mapstructure:"write_timeout" validate:"required,min=1"`
|
||||
ShutdownTimeout int `mapstructure:"shutdown_timeout" validate:"required,min=1"`
|
||||
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
||||
AllowOrigins []string `mapstructure:"allow_origins"`
|
||||
Host string `mapstructure:"host" validate:"required"`
|
||||
Port int `mapstructure:"port" validate:"required,min=1,max=65535"`
|
||||
ReadTimeout int `mapstructure:"read_timeout" validate:"required,min=1"`
|
||||
WriteTimeout int `mapstructure:"write_timeout" validate:"required,min=1"`
|
||||
ShutdownTimeout int `mapstructure:"shutdown_timeout" validate:"required,min=1"`
|
||||
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
||||
AllowOrigins []string `mapstructure:"allow_origins"`
|
||||
}
|
||||
|
||||
// DatabaseConfig chứa thông tin cấu hình database
|
||||
type DatabaseConfig struct {
|
||||
Driver string `mapstructure:"driver" validate:"required,oneof=postgres mysql sqlite"`
|
||||
Host string `mapstructure:"host" validate:"required_if=Driver postgres,required_if=Driver mysql"`
|
||||
Port int `mapstructure:"port" validate:"required_if=Driver postgres,required_if=Driver mysql,min=1,max=65535"`
|
||||
Username string `mapstructure:"username" validate:"required_if=Driver postgres,required_if=Driver mysql"`
|
||||
Password string `mapstructure:"password" validate:"required_if=Driver postgres,required_if=Driver mysql"`
|
||||
Database string `mapstructure:"database" validate:"required"`
|
||||
SSLMode string `mapstructure:"ssl_mode" validate:"omitempty,oneof=disable prefer require verify-ca verify-full"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns" validate:"min=1"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns" validate:"min=1"`
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime" validate:"min=1"`
|
||||
MigrationPath string `mapstructure:"migration_path" validate:"required"`
|
||||
Driver string `mapstructure:"driver" validate:"required,oneof=postgres mysql sqlite"`
|
||||
Host string `mapstructure:"host" validate:"required_if=Driver postgres,required_if=Driver mysql"`
|
||||
Port int `mapstructure:"port" validate:"required_if=Driver postgres,required_if=Driver mysql,min=1,max=65535"`
|
||||
Username string `mapstructure:"username" validate:"required_if=Driver postgres,required_if=Driver mysql"`
|
||||
Password string `mapstructure:"password" validate:"required_if=Driver postgres,required_if=Driver mysql"`
|
||||
Database string `mapstructure:"database" validate:"required"`
|
||||
SSLMode string `mapstructure:"ssl_mode" validate:"omitempty,oneof=disable prefer require verify-ca verify-full"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns" validate:"min=1"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns" validate:"min=1"`
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime" validate:"min=1"`
|
||||
MigrationPath string `mapstructure:"migration_path" validate:"required"`
|
||||
}
|
||||
|
||||
// JWTConfig chứa cấu hình cho JWT
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret" validate:"required,min=32"`
|
||||
Expiration int `mapstructure:"expiration" validate:"required,min=1"` // in minutes
|
||||
Secret string `mapstructure:"secret" validate:"required,min=32"`
|
||||
AccessTokenExpire int `mapstructure:"access_token_expire" validate:"required,min=1"` // in minutes
|
||||
RefreshTokenExpire int `mapstructure:"refresh_token_expire" validate:"required,min=1"` // in days
|
||||
Algorithm string `mapstructure:"algorithm" validate:"required,oneof=HS256 HS384 HS512 RS256"`
|
||||
Issuer string `mapstructure:"issuer" validate:"required"`
|
||||
Audience []string `mapstructure:"audience" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
// Config là struct tổng thể chứa tất cả các cấu hình
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app" validate:"required"`
|
||||
Server ServerConfig `mapstructure:"server" validate:"required"`
|
||||
Database DatabaseConfig `mapstructure:"database" validate:"required"`
|
||||
Logger LoggerConfig `mapstructure:"logger" validate:"required"`
|
||||
JWT JWTConfig `mapstructure:"jwt" validate:"required"`
|
||||
App AppConfig `mapstructure:"app" validate:"required"`
|
||||
Server ServerConfig `mapstructure:"server" validate:"required"`
|
||||
Database DatabaseConfig `mapstructure:"database" validate:"required"`
|
||||
Logger LoggerConfig `mapstructure:"logger" validate:"required"`
|
||||
}
|
||||
|
||||
// LoggerConfig chứa cấu hình cho logger
|
||||
type LoggerConfig struct {
|
||||
Level string `mapstructure:"level" validate:"required,oneof=debug info warn error"`
|
||||
Level string `mapstructure:"level" validate:"required,oneof=debug info warn error"`
|
||||
}
|
||||
|
||||
@ -1,35 +1,43 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"starter-kit/internal/helper/logger"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Logger middleware for logging HTTP requests
|
||||
// Logger là một middleware đơn giản để ghi log các request
|
||||
func Logger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Generate request ID
|
||||
requestID := uuid.New().String()
|
||||
c.Set("RequestID", requestID)
|
||||
|
||||
// Start timer
|
||||
// Ghi thời gian bắt đầu xử lý request
|
||||
start := time.Now()
|
||||
|
||||
// Process request
|
||||
// Xử lý request
|
||||
c.Next()
|
||||
|
||||
// Log request details
|
||||
logger.WithFields(logger.Fields{
|
||||
"request_id": requestID,
|
||||
"method": c.Request.Method,
|
||||
"path": c.Request.URL.Path,
|
||||
"status": c.Writer.Status(),
|
||||
"latency": time.Since(start).String(),
|
||||
"client_ip": c.ClientIP(),
|
||||
"user_agent": c.Request.UserAgent(),
|
||||
}).Info("HTTP Request")
|
||||
// Tính thời gian xử lý
|
||||
latency := time.Since(start)
|
||||
|
||||
|
||||
// Lấy thông tin response
|
||||
status := c.Writer.Status()
|
||||
method := c.Request.Method
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Ghi log
|
||||
logMessage := "[GIN] " + time.Now().Format("2006/01/02 - 15:04:05") +
|
||||
" | " + method +
|
||||
" | " + path +
|
||||
" | " + latency.String() +
|
||||
" | " + c.ClientIP() +
|
||||
" | " + c.Request.UserAgent()
|
||||
|
||||
if status >= 400 {
|
||||
// Log lỗi
|
||||
logMessage += " | " + c.Errors.ByType(gin.ErrorTypePrivate).String()
|
||||
_, _ = gin.DefaultErrorWriter.Write([]byte(logMessage + "\n"))
|
||||
} else {
|
||||
// Log thông thường
|
||||
_, _ = gin.DefaultWriter.Write([]byte(logMessage + "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
internal/transport/http/middleware/middleware.go
Normal file
76
internal/transport/http/middleware/middleware.go
Normal file
@ -0,0 +1,76 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// CORSConfig chứa cấu hình CORS
|
||||
type CORSConfig struct {
|
||||
AllowOrigins []string `yaml:"allow_origins"`
|
||||
AllowMethods []string `yaml:"allow_methods"`
|
||||
AllowHeaders []string `yaml:"allow_headers"`
|
||||
}
|
||||
|
||||
// DefaultCORSConfig trả về cấu hình CORS mặc định
|
||||
func DefaultCORSConfig() CORSConfig {
|
||||
return CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"},
|
||||
}
|
||||
}
|
||||
|
||||
// CORS middleware xử lý CORS
|
||||
func CORS(config CORSConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimiterConfig chứa cấu hình rate limiting
|
||||
type RateLimiterConfig struct {
|
||||
Rate int `yaml:"rate"` // Số request tối đa trong khoảng thời gian
|
||||
}
|
||||
|
||||
// DefaultRateLimiterConfig trả về cấu hình rate limiting mặc định
|
||||
func DefaultRateLimiterConfig() RateLimiterConfig {
|
||||
return RateLimiterConfig{
|
||||
Rate: 100, // 100 requests per minute
|
||||
}
|
||||
}
|
||||
|
||||
// NewRateLimiter tạo middleware rate limiting
|
||||
func NewRateLimiter(config RateLimiterConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// TODO: Implement rate limiting logic
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityConfig chứa tất cả các cấu hình bảo mật
|
||||
type SecurityConfig struct {
|
||||
// CORS configuration
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
// Rate limiting configuration
|
||||
RateLimit RateLimiterConfig `yaml:"rate_limit"`
|
||||
}
|
||||
|
||||
// DefaultSecurityConfig trả về cấu hình bảo mật mặc định
|
||||
func DefaultSecurityConfig() SecurityConfig {
|
||||
return SecurityConfig{
|
||||
CORS: DefaultCORSConfig(),
|
||||
RateLimit: DefaultRateLimiterConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
// Apply áp dụng tất cả các middleware bảo mật vào router
|
||||
func (c *SecurityConfig) Apply(r *gin.Engine) {
|
||||
// Áp dụng CORS middleware
|
||||
r.Use(CORS(c.CORS))
|
||||
|
||||
// Áp dụng rate limiting
|
||||
r.Use(NewRateLimiter(c.RateLimit))
|
||||
}
|
||||
74
internal/transport/http/middleware/middleware_test.go
Normal file
74
internal/transport/http/middleware/middleware_test.go
Normal file
@ -0,0 +1,74 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"starter-kit/internal/transport/http/middleware"
|
||||
)
|
||||
|
||||
func TestCORS(t *testing.T) {
|
||||
// Tạo router mới
|
||||
r := gin.New()
|
||||
|
||||
// Lấy cấu hình mặc định
|
||||
config := middleware.DefaultSecurityConfig()
|
||||
|
||||
// Tùy chỉnh cấu hình CORS
|
||||
config.CORS.AllowOrigins = []string{"https://example.com"}
|
||||
|
||||
// Áp dụng middleware
|
||||
config.Apply(r)
|
||||
|
||||
|
||||
// Thêm route test
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Hello, World!"})
|
||||
})
|
||||
|
||||
// Tạo test server
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Test CORS
|
||||
t.Run("Test CORS", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/test", nil)
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
err := resp.Body.Close()
|
||||
assert.NoError(t, err, "Failed to close response body")
|
||||
}()
|
||||
|
||||
assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin"), "CORS header not set correctly")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRateLimit(t *testing.T) {
|
||||
// Test rate limiting (chỉ kiểm tra xem middleware có được áp dụng không)
|
||||
config := middleware.DefaultSecurityConfig()
|
||||
config.RateLimit.Rate = 10 // 10 requests per minute
|
||||
|
||||
r := gin.New()
|
||||
config.Apply(r)
|
||||
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Gửi một request để kiểm tra xem server có chạy không
|
||||
resp, err := http.Get(ts.URL)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
err = resp.Body.Close()
|
||||
assert.NoError(t, err, "Failed to close response body")
|
||||
}
|
||||
@ -3,14 +3,18 @@ package http
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"starter-kit/internal/adapter/persistence"
|
||||
"starter-kit/internal/domain/role"
|
||||
"starter-kit/internal/helper/config"
|
||||
"starter-kit/internal/service"
|
||||
"starter-kit/internal/transport/http/handler"
|
||||
"starter-kit/internal/transport/http/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SetupRouter cấu hình router cho HTTP server
|
||||
@ -19,22 +23,22 @@ func SetupRouter(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
||||
if cfg.App.Environment == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
|
||||
router := gin.New()
|
||||
|
||||
|
||||
// Logger middleware
|
||||
router.Use(middleware.Logger())
|
||||
|
||||
|
||||
// Recovery middleware
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
|
||||
// CORS middleware
|
||||
router.Use(middleware.CORS())
|
||||
|
||||
|
||||
// Khởi tạo repositories
|
||||
userRepo := persistence.NewUserRepository(db)
|
||||
roleRepo := persistence.NewRoleRepository(db)
|
||||
|
||||
|
||||
// Khởi tạo services
|
||||
authSvc := service.NewAuthService(
|
||||
userRepo,
|
||||
@ -42,21 +46,21 @@ func SetupRouter(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
||||
cfg.JWT.Secret,
|
||||
time.Duration(cfg.JWT.Expiration)*time.Minute,
|
||||
)
|
||||
|
||||
|
||||
// Khởi tạo middleware
|
||||
authMiddleware := middleware.NewAuthMiddleware(authSvc)
|
||||
|
||||
|
||||
// Khởi tạo các handlers
|
||||
healthHandler := handler.NewHealthHandler(cfg)
|
||||
authHandler := handler.NewAuthHandler(authSvc)
|
||||
|
||||
|
||||
// Public routes - Không yêu cầu xác thực
|
||||
public := router.Group("/api/v1")
|
||||
{
|
||||
// Health check
|
||||
public.GET("/ping", healthHandler.Ping)
|
||||
public.GET("/health", healthHandler.HealthCheck)
|
||||
|
||||
|
||||
// Auth routes
|
||||
authGroup := public.Group("/auth")
|
||||
{
|
||||
@ -65,7 +69,7 @@ func SetupRouter(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
||||
authGroup.POST("/refresh", authHandler.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Protected routes - Yêu cầu xác thực
|
||||
protected := router.Group("/api/v1")
|
||||
protected.Use(authMiddleware.Authenticate())
|
||||
@ -75,16 +79,16 @@ func SetupRouter(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
||||
{
|
||||
authGroup.POST("/logout", authHandler.Logout)
|
||||
}
|
||||
|
||||
|
||||
// User routes
|
||||
usersGroup := protected.Group("/users")
|
||||
{
|
||||
usersGroup.GET("", authMiddleware.RequireRole(role.Admin, role.Manager), /* userHandler.ListUsers */)
|
||||
usersGroup.GET("/:id", /* userHandler.GetUser */)
|
||||
usersGroup.PUT("/:id", /* userHandler.UpdateUser */)
|
||||
usersGroup.DELETE("/:id", authMiddleware.RequireRole(role.Admin), /* userHandler.DeleteUser */)
|
||||
usersGroup.GET("", authMiddleware.RequireRole(role.Admin, role.Manager) /* userHandler.ListUsers */)
|
||||
usersGroup.GET("/:id" /* userHandler.GetUser */)
|
||||
usersGroup.PUT("/:id" /* userHandler.UpdateUser */)
|
||||
usersGroup.DELETE("/:id", authMiddleware.RequireRole(role.Admin) /* userHandler.DeleteUser */)
|
||||
}
|
||||
|
||||
|
||||
// Admin routes
|
||||
adminGroup := protected.Group("/admin")
|
||||
adminGroup.Use(authMiddleware.RequireRole(role.Admin))
|
||||
@ -93,6 +97,6 @@ func SetupRouter(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
||||
adminGroup.Group("/roles")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
@ -1,29 +1,32 @@
|
||||
# App Configuration
|
||||
APP_APP_NAME="ULFlow Starter Kit"
|
||||
APP_APP_VERSION=0.1.0
|
||||
APP_APP_ENVIRONMENT=development
|
||||
APP_APP_TIMEZONE=Asia/Ho_Chi_Minh
|
||||
APP_NAME="ULFlow Starter Kit"
|
||||
APP_VERSION="0.1.0"
|
||||
APP_ENVIRONMENT="development"
|
||||
APP_TIMEZONE="Asia/Ho_Chi_Minh"
|
||||
|
||||
# Logger Configuration
|
||||
APP_LOGGER_LEVEL=info # debug, info, warn, error
|
||||
LOG_LEVEL="info" # debug, info, warn, error
|
||||
|
||||
# Server Configuration
|
||||
APP_SERVER_HOST=0.0.0.0
|
||||
APP_SERVER_PORT=3000
|
||||
APP_SERVER_READ_TIMEOUT=15
|
||||
APP_SERVER_WRITE_TIMEOUT=15
|
||||
APP_SERVER_SHUTDOWN_TIMEOUT=30
|
||||
APP_SERVER_ALLOW_ORIGINS=*
|
||||
SERVER_HOST="0.0.0.0"
|
||||
SERVER_PORT=3000
|
||||
SERVER_READ_TIMEOUT=15
|
||||
SERVER_WRITE_TIMEOUT=15
|
||||
SERVER_SHUTDOWN_TIMEOUT=30
|
||||
|
||||
# Database Configuration
|
||||
APP_DATABASE_DRIVER=postgres
|
||||
APP_DATABASE_HOST=localhost
|
||||
APP_DATABASE_PORT=5432
|
||||
APP_DATABASE_USERNAME=postgres
|
||||
APP_DATABASE_PASSWORD=postgres
|
||||
APP_DATABASE_DATABASE=ulflow
|
||||
APP_DATABASE_SSL_MODE=disable
|
||||
APP_DATABASE_MAX_OPEN_CONNS=25
|
||||
APP_DATABASE_MAX_IDLE_CONNS=5
|
||||
APP_DATABASE_CONN_MAX_LIFETIME=300
|
||||
APP_DATABASE_MIGRATION_PATH=migrations
|
||||
DATABASE_DRIVER="postgres"
|
||||
DATABASE_HOST="localhost"
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_USERNAME="postgres"
|
||||
DATABASE_PASSWORD="postgres"
|
||||
DATABASE_NAME="ulflow"
|
||||
DATABASE_SSLMODE="disable"
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET="your-32-byte-base64-encoded-secret-key-here"
|
||||
JWT_ACCESS_TOKEN_EXPIRE=15
|
||||
JWT_REFRESH_TOKEN_EXPIRE=30
|
||||
JWT_ALGORITHM="HS256"
|
||||
JWT_ISSUER="ulflow-starter-kit"
|
||||
JWT_AUDIENCE="ulflow-web"
|
||||
Loading…
x
Reference in New Issue
Block a user