diff --git a/.feature-flags.json b/.feature-flags.json index 70f0990..49be960 100644 --- a/.feature-flags.json +++ b/.feature-flags.json @@ -1,5 +1,5 @@ { "enable_database": { - "enabled": false + "enabled": true } } \ No newline at end of file diff --git a/configs/config.yaml b/configs/config.yaml index 66f9ea9..e217a98 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -29,3 +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: "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 diff --git a/configs/security.example.yaml b/configs/security.example.yaml new file mode 100644 index 0000000..9d39b64 --- /dev/null +++ b/configs/security.example.yaml @@ -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=()" diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md new file mode 100644 index 0000000..1b0ddd7 --- /dev/null +++ b/docs/AUTHENTICATION.md @@ -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) +``` diff --git a/docs/review.md b/docs/review.md index 2962122..b373e55 100644 --- a/docs/review.md +++ b/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. \ No newline at end of file diff --git a/docs/session_20240524.md b/docs/session_20240524.md new file mode 100644 index 0000000..62b968d --- /dev/null +++ b/docs/session_20240524.md @@ -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* diff --git a/go.mod b/go.mod index 5963f4c..1f1d8be 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -65,6 +66,7 @@ require ( golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.11.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 diff --git a/go.sum b/go.sum index 3f83c12..32f9c24 100644 --- a/go.sum +++ b/go.sum @@ -169,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= @@ -417,6 +419,8 @@ golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/internal/helper/config/load.go b/internal/helper/config/load.go index 794c218..f681003 100644 --- a/internal/helper/config/load.go +++ b/internal/helper/config/load.go @@ -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,93 @@ 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) + os.Setenv(key, val) + } + + 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 } diff --git a/internal/helper/config/types.go b/internal/helper/config/types.go index 2dbf205..34d5d50 100644 --- a/internal/helper/config/types.go +++ b/internal/helper/config/types.go @@ -34,6 +34,19 @@ type DatabaseConfig struct { MigrationPath string `mapstructure:"migration_path" validate:"required"` } +<<<<<<< Updated upstream +======= +// JWTConfig chứa cấu hình cho JWT +type JWTConfig struct { + 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"` +} + +>>>>>>> Stashed changes // 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"` diff --git a/internal/transport/http/middleware/README.md b/internal/transport/http/middleware/README.md new file mode 100644 index 0000000..f86ecf8 --- /dev/null +++ b/internal/transport/http/middleware/README.md @@ -0,0 +1,110 @@ +# HTTP Middleware Bảo Mật + +Gói này cung cấp các middleware bảo mật cho ứng dụng HTTP sử dụng Gin framework. + +## Các Middleware Đã Có + +1. **CORS (Cross-Origin Resource Sharing)** + - Kiểm soát truy cập tài nguyên từ các domain khác + - Hỗ trợ preflight requests + - Tùy chỉnh headers và methods cho phép + +2. **Rate Limiting** + - Giới hạn số lượng request từ một IP trong khoảng thời gian nhất định + - Hỗ trợ loại trừ các route khỏi việc giới hạn + - Tự động dọn dẹp các bộ đếm cũ + +3. **Security Headers** + - Thêm các HTTP headers bảo mật vào response + - Hỗ trợ CSP, HSTS, X-Frame-Options, v.v. + - Tùy chỉnh các chính sách bảo mật + +## Cách Sử Dụng + +```go +import ( + "github.com/gin-gonic/gin" + "your-project/internal/transport/http/middleware" +) + +func main() { + r := gin.New() + + // Lấy cấu hình mặc định + config := middleware.DefaultSecurityConfig() + + // Tùy chỉnh cấu hình nếu cần + config.CORS.AllowedOrigins = []string{"https://example.com"} + config.RateLimit.Rate = 100 // 100 requests mỗi phút + config.Headers.ContentSecurityPolicy = "default-src 'self'" + + // Áp dụng tất cả các middleware bảo mật + config.Apply(r) + + + // Thêm các route của bạn ở đây + r.GET("/api/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // Khởi động server + r.Run(":8080") +} +``` + +## Cấu Hình Chi Tiết + +### CORS + +```go +type CORSConfig struct { + AllowedOrigins []string `yaml:"allowed_origins"` + AllowedMethods []string `yaml:"allowed_methods"` + AllowedHeaders []string `yaml:"allowed_headers"` + ExposedHeaders []string `yaml:"exposed_headers"` + AllowCredentials bool `yaml:"allow_credentials"` + MaxAge time.Duration `yaml:"max_age"` + Debug bool `yaml:"debug"` +} +``` + +### Rate Limiting + +```go +type RateLimiterConfig struct { + Rate int `yaml:"rate"` + Window time.Duration `yaml:"window"` + ExcludedRoutes []string `yaml:"excluded_routes"` +} +``` + +### Security Headers + +```go +type SecurityHeadersConfig struct { + Enabled bool `yaml:"enabled"` + ContentSecurityPolicy string `yaml:"content_security_policy"` + CrossOriginResourcePolicy string `yaml:"cross_origin_resource_policy"` + CrossOriginOpenerPolicy string `yaml:"cross_origin_opener_policy"` + CrossOriginEmbedderPolicy string `yaml:"cross_origin_embedder_policy"` + ReferrerPolicy string `yaml:"referrer_policy"` + FeaturePolicy string `yaml:"feature_policy"` + FrameOptions string `yaml:"frame_options"` + XSSProtection string `yaml:"xss_protection"` + ContentTypeOptions string `yaml:"content_type_options"` + StrictTransportSecurity string `yaml:"strict_transport_security"` + PermissionsPolicy string `yaml:"permissions_policy"` +} +``` + +## Kiểm Thử + +Chạy các test bằng lệnh: + +```bash +go test -v ./... +``` + +## Giấy Phép + +MIT diff --git a/internal/transport/http/middleware/config.go b/internal/transport/http/middleware/config.go new file mode 100644 index 0000000..7aaa15b --- /dev/null +++ b/internal/transport/http/middleware/config.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +// 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"` + // Security headers configuration + Headers SecurityHeadersConfig `yaml:"headers"` +} + +// DefaultSecurityConfig trả về cấu hình bảo mật mặc định +func DefaultSecurityConfig() SecurityConfig { + return SecurityConfig{ + CORS: DefaultCORSConfig(), + RateLimit: DefaultRateLimiterConfig(), + Headers: DefaultSecurityHeadersConfig(), + } +} + +// 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)) + + // Áp dụng security headers + r.Use(SecurityHeadersMiddleware(c.Headers)) +} diff --git a/internal/transport/http/middleware/example_test.go b/internal/transport/http/middleware/example_test.go new file mode 100644 index 0000000..e82d423 --- /dev/null +++ b/internal/transport/http/middleware/example_test.go @@ -0,0 +1,84 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "starter-kit/internal/transport/http/middleware" +) + +func TestSecurityMiddlewares(t *testing.T) { + // Tạo router mới + r := gin.New() + + // Lấy cấu hình bảo mật mặc định + config := middleware.DefaultSecurityConfig() + + // Tùy chỉnh cấu hình nếu cần + config.CORS.AllowedOrigins = []string{"https://example.com"} + config.RateLimit.Rate = 100 // 100 requests per minute + config.Headers.ContentSecurityPolicy = "default-src 'self'; script-src 'self'" + + // Áp dụng tất cả các middleware bảo mật + config.Apply(r) + + // Thêm một route test + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "Hello, World!"}) + }) + + // Tạo một test server + ts := httptest.NewServer(r) + defer ts.Close() + + // Test CORS + t.Run("Test CORS", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/test") + assert.NoError(t, err) + defer resp.Body.Close() + + // Kiểm tra CORS header + assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin")) + }) + + // Test rate limiting + t.Run("Test Rate Limiting", func(t *testing.T) { + // Gửi nhiều request để kiểm tra rate limiting + for i := 0; i < 110; i++ { + resp, err := http.Get(ts.URL + "/test") + assert.NoError(t, err) + resp.Body.Close() + + if i >= 100 { + // Sau 100 request, server sẽ trả về 429 + assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + } + + // Đợi một chút để tránh bị block bởi rate limiting + time.Sleep(10 * time.Millisecond) + } + }) + + // Test security headers + t.Run("Test Security Headers", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/test") + assert.NoError(t, err) + defer resp.Body.Close() + + // Kiểm tra các security headers + headers := []string{ + "X-Frame-Options", + "X-Content-Type-Options", + "X-XSS-Protection", + "Content-Security-Policy", + } + + for _, h := range headers { + assert.NotEmpty(t, resp.Header.Get(h), "Header %s should not be empty", h) + } + }) +} diff --git a/internal/transport/http/middleware/rate_limiter.go b/internal/transport/http/middleware/rate_limiter.go new file mode 100644 index 0000000..1c12a60 --- /dev/null +++ b/internal/transport/http/middleware/rate_limiter.go @@ -0,0 +1,141 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +// RateLimiterConfig chứa cấu hình cho rate limiting +type RateLimiterConfig struct { + // Số request tối đa trong khoảng thời gian + Rate int `yaml:"rate"` + // Khoảng thời gian giữa các request + Window time.Duration `yaml:"window"` + // Danh sách các route được bỏ qua rate limiting + ExcludedRoutes []string `yaml:"excluded_routes"` +} + +// IPRateLimiter quản lý rate limiting theo IP +type IPRateLimiter struct { + ips map[string]*rate.Limiter + mu *sync.RWMutex + r rate.Limit + b int + config RateLimiterConfig + excludedRoutes map[string]bool +} + +// NewIPRateLimiter tạo mới một IPRateLimiter +func NewIPRateLimiter(r rate.Limit, b int, config RateLimiterConfig) *IPRateLimiter { + excluded := make(map[string]bool) + for _, route := range config.ExcludedRoutes { + excluded[route] = true + } + + i := &IPRateLimiter{ + ips: make(map[string]*rate.Limiter), + mu: &sync.RWMutex{}, + r: r, + b: b, + config: config, + excludedRoutes: excluded, + } + + // Clean up old limiters periodically + go i.cleanupOldLimiters() + return i +} + +// cleanupOldLimiters dọn dẹp các rate limiter cũ +func (i *IPRateLimiter) cleanupOldLimiters() { + for { + time.Sleep(time.Hour) // Dọn dẹp mỗi giờ + + i.mu.Lock() + now := time.Now() + for ip, limiter := range i.ips { + // Nếu không có request nào trong 1 giờ, xóa khỏi map + reservation := limiter.ReserveN(now, 0) + if reservation.Delay() == rate.InfDuration { + delete(i.ips, ip) + } else { + reservation.CancelAt(now) // Hủy reservation vì chúng ta chỉ kiểm tra + } + } + i.mu.Unlock() + } +} + +// GetLimiter lấy hoặc tạo mới rate limiter cho IP +func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter { + i.mu.Lock() + defer i.mu.Unlock() + + limiter, exists := i.ips[ip] + if !exists { + limiter = rate.NewLimiter(i.r, i.b) + i.ips[ip] = limiter + } + + return limiter +} + +// Middleware tạo middleware rate limiting cho Gin +func (i *IPRateLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Bỏ qua rate limiting cho các route được chỉ định + if _, excluded := i.excludedRoutes[c.FullPath()]; excluded { + c.Next() + return + } + + // Lấy IP của client (xử lý đằng sau proxy nếu cần) + ip := c.ClientIP() + + // Lấy rate limiter cho IP này + limiter := i.GetLimiter(ip) + + // Kiểm tra rate limit + if !limiter.Allow() { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "status": "error", + "message": "Too many requests, please try again later.", + }) + return + } + + c.Next() + } +} + +// NewRateLimiterMiddleware tạo middleware rate limiting mới +func NewRateLimiter(config RateLimiterConfig) gin.HandlerFunc { + // Chuyển đổi rate từ requests/giây sang rate.Limit + r := rate.Every(time.Second / time.Duration(config.Rate)) + // Tạo rate limiter mới + limiter := NewIPRateLimiter(r, config.Rate, config) + // Trả về middleware + return limiter.Middleware() +} + +// DefaultRateLimiterConfig trả về cấu hình mặc định cho rate limiter +func DefaultRateLimiterConfig() RateLimiterConfig { + return RateLimiterConfig{ + Rate: 100, // 100 requests + Window: time.Minute, // mỗi phút + ExcludedRoutes: []string{"/health"}, // Các route không áp dụng rate limiting + } +} + +// RateLimitMiddleware tạo middleware rate limiting đơn giản (tương thích ngược) +func RateLimitMiddleware(requestsPerMinute int) gin.HandlerFunc { + config := RateLimiterConfig{ + Rate: requestsPerMinute, + Window: time.Minute, + } + return NewRateLimiter(config) +} diff --git a/internal/transport/http/middleware/security_headers.go b/internal/transport/http/middleware/security_headers.go new file mode 100644 index 0000000..a78bdd9 --- /dev/null +++ b/internal/transport/http/middleware/security_headers.go @@ -0,0 +1,115 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "strings" +) + +// SecurityHeadersConfig chứa cấu hình cho các security headers +type SecurityHeadersConfig struct { + // Có nên thêm các security headers hay không + Enabled bool `yaml:"enabled"` + // Chính sách bảo mật nội dung (Content Security Policy) + ContentSecurityPolicy string `yaml:"content_security_policy"` + // Chính sách bảo mật truy cập tài nguyên (Cross-Origin) + CrossOriginResourcePolicy string `yaml:"cross_origin_resource_policy"` + // Chính sách tương tác giữa các site (Cross-Origin) + CrossOriginOpenerPolicy string `yaml:"cross_origin_opener_policy"` + // Chính sách nhúng tài nguyên (Cross-Origin) + CrossOriginEmbedderPolicy string `yaml:"cross_origin_embedder_policy"` + // Chính sách tham chiếu (Referrer-Policy) + ReferrerPolicy string `yaml:"referrer_policy"` + // Chính sách sử dụng các tính năng trình duyệt (Feature-Policy) + FeaturePolicy string `yaml:"feature_policy"` + // Chính sách bảo vệ clickjacking (X-Frame-Options) + FrameOptions string `yaml:"frame_options"` + // Chính sách bảo vệ XSS (X-XSS-Protection) + XSSProtection string `yaml:"xss_protection"` + // Chính sách MIME type sniffing (X-Content-Type-Options) + ContentTypeOptions string `yaml:"content_type_options"` + // Chính sách Strict-Transport-Security (HSTS) + StrictTransportSecurity string `yaml:"strict_transport_security"` + // Chính sách Permissions-Policy (thay thế cho Feature-Policy) + PermissionsPolicy string `yaml:"permissions_policy"` +} + +// DefaultSecurityHeadersConfig trả về cấu hình mặc định cho security headers +func DefaultSecurityHeadersConfig() SecurityHeadersConfig { + return SecurityHeadersConfig{ + Enabled: true, + ContentSecurityPolicy: "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'", + CrossOriginResourcePolicy: "same-origin", + CrossOriginOpenerPolicy: "same-origin", + CrossOriginEmbedderPolicy: "require-corp", + ReferrerPolicy: "no-referrer-when-downgrade", + FeaturePolicy: "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'", + FrameOptions: "DENY", + XSSProtection: "1; mode=block", + ContentTypeOptions: "nosniff", + StrictTransportSecurity: "max-age=31536000; includeSubDomains; preload", + PermissionsPolicy: "geolocation=(), microphone=(), camera=()", + } +} + +// SecurityHeadersMiddleware thêm các security headers vào response +func SecurityHeadersMiddleware(config SecurityHeadersConfig) gin.HandlerFunc { + if !config.Enabled { + // Nếu không bật, trả về middleware trống + return func(c *gin.Context) { + c.Next() + } + } + + // Chuẩn hóa các giá trị cấu hình + if config.ContentSecurityPolicy == "" { + config.ContentSecurityPolicy = "default-src 'self'" + } + if config.FrameOptions == "" { + config.FrameOptions = "DENY" + } + if config.XSSProtection == "" { + config.XSSProtection = "1; mode=block" + } + if config.ContentTypeOptions == "" { + config.ContentTypeOptions = "nosniff" + } + + return func(c *gin.Context) { + // Thêm các security headers + if config.ContentSecurityPolicy != "" { + c.Writer.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) + } + if config.CrossOriginResourcePolicy != "" { + c.Writer.Header().Set("Cross-Origin-Resource-Policy", config.CrossOriginResourcePolicy) + } + if config.CrossOriginOpenerPolicy != "" { + c.Writer.Header().Set("Cross-Origin-Opener-Policy", config.CrossOriginOpenerPolicy) + } + if config.CrossOriginEmbedderPolicy != "" { + c.Writer.Header().Set("Cross-Origin-Embedder-Policy", config.CrossOriginEmbedderPolicy) + } + if config.ReferrerPolicy != "" { + c.Writer.Header().Set("Referrer-Policy", config.ReferrerPolicy) + } + if config.FeaturePolicy != "" { + c.Writer.Header().Set("Feature-Policy", config.FeaturePolicy) + } + if config.FrameOptions != "" { + c.Writer.Header().Set("X-Frame-Options", config.FrameOptions) + } + if config.XSSProtection != "" { + c.Writer.Header().Set("X-XSS-Protection", config.XSSProtection) + } + if config.ContentTypeOptions != "" { + c.Writer.Header().Set("X-Content-Type-Options", config.ContentTypeOptions) + } + if config.StrictTransportSecurity != "" && strings.ToLower(c.Request.URL.Scheme) == "https" { + c.Writer.Header().Set("Strict-Transport-Security", config.StrictTransportSecurity) + } + if config.PermissionsPolicy != "" { + c.Writer.Header().Set("Permissions-Policy", config.PermissionsPolicy) + } + + c.Next() + } +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index cdf8de7..dcd56d3 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -22,8 +22,29 @@ func SetupRouter(cfg *config.Config) *gin.Engine { // Recovery middleware router.Use(gin.Recovery()) +<<<<<<< Updated upstream // CORS middleware nếu cần // router.Use(middleware.CORS()) +======= + // CORS middleware + router.Use(middleware.CORS(middleware.DefaultCORSConfig())) + + // Khởi tạo repositories + userRepo := persistence.NewUserRepository(db) + roleRepo := persistence.NewRoleRepository(db) + + // Khởi tạo services + authSvc := service.NewAuthService( + userRepo, + roleRepo, + cfg.JWT.Secret, + time.Duration(cfg.JWT.AccessTokenExpire)*time.Minute, + cfg.JWT.RefreshTokenExpire * 24, // Convert days to hours + ) + + // Khởi tạo middleware + authMiddleware := middleware.NewAuthMiddleware(authSvc) +>>>>>>> Stashed changes // Khởi tạo các handlers healthHandler := handler.NewHealthHandler(cfg) diff --git a/templates/.env.example b/templates/.env.example index 5ec63f1..6dd30b0 100644 --- a/templates/.env.example +++ b/templates/.env.example @@ -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" \ No newline at end of file