522 lines
16 KiB
Markdown
522 lines
16 KiB
Markdown
# Tài Liệu Luồng Xác Thực Qua Portal SSO Bên Ngoài
|
|
|
|
**Hệ thống:** CTI Web Services (Threat Intelligence Platform)
|
|
**Phiên bản:** CTI v4
|
|
**Cập nhật lần cuối:** 2026-04-22
|
|
|
|
---
|
|
|
|
## 1. Tổng Quan Kiến Trúc
|
|
|
|
Hệ thống CTI Web hỗ trợ **hai phương thức xác thực** song song:
|
|
|
|
| Phương thức | Endpoint | Mô tả |
|
|
|---|---|---|
|
|
| **RADIUS Login** | `POST /cti/login/` | Đăng nhập trực tiếp qua trang login nội bộ, xác thực với FreeRADIUS |
|
|
| **SSO Portal Auth** | `GET /cti/portal_auth/` | Đăng nhập qua cổng thông tin SSO bên ngoài bằng JWT token |
|
|
|
|
Tài liệu này tập trung vào **luồng SSO Portal Auth**.
|
|
|
|
---
|
|
|
|
## 2. Kiến Trúc Hệ Thống
|
|
|
|
```
|
|
Internet / Mạng nội bộ
|
|
│
|
|
▼
|
|
┌─────────────────────────────┐
|
|
│ Nginx Reverse Proxy │
|
|
│ Host: 10.144.13.213 │
|
|
│ Port: 443 (HTTPS/TLS) │
|
|
│ → Redirect HTTP → HTTPS │
|
|
└──────────────┬──────────────┘
|
|
│ proxy_pass → http://192.168.100.100:8001/cti
|
|
▼
|
|
┌─────────────────────────────┐
|
|
│ Django Web App (Gunicorn) │
|
|
│ Internal: 192.168.100.100 │
|
|
│ Port: 8001 │
|
|
│ Root path: /cti │
|
|
└──────────────┬──────────────┘
|
|
│
|
|
┌───────┴───────┐
|
|
│ │
|
|
▼ ▼
|
|
┌────────────┐ ┌──────────────────┐
|
|
│ MySQL DB │ │ SSO Server │
|
|
│ 192.168. │ │ 10.144.47.157 │
|
|
│ 100.101 │ │ HTTPS │
|
|
└────────────┘ └──────────────────┘
|
|
```
|
|
|
|
**Cấu hình Docker Network:**
|
|
- Network: `cti_network` (subnet `192.168.100.0/24`)
|
|
- Proxy container: Nginx
|
|
- Web container: Django + Gunicorn
|
|
- DB container: MySQL 9.2.0
|
|
|
|
---
|
|
|
|
## 3. Luồng Xác Thực SSO Chi Tiết
|
|
|
|
### 3.1 Sơ Đồ Luồng Tổng Quát
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
actor User as Người dùng
|
|
participant Portal as SSO Portal<br/>(External)
|
|
participant Nginx as Nginx Proxy<br/>10.144.13.213:443
|
|
participant Django as Django App<br/>192.168.100.100:8001
|
|
participant SSOServer as SSO Validate Server<br/>10.144.47.157
|
|
participant DB as MySQL Database<br/>192.168.100.101
|
|
|
|
User->>Portal: Đăng nhập SSO Portal
|
|
Portal-->>User: JWT Token được cấp phát
|
|
Portal->>Nginx: Redirect: GET /cti/portal_auth/?jwt=<token>&host=<url>
|
|
Nginx->>Django: Forward request (X-Forwarded-For, X-Forwarded-Proto)
|
|
Django->>Django: Bước 0: Kiểm tra user đã authenticated chưa?
|
|
|
|
alt Đã đăng nhập
|
|
Django-->>User: Redirect tới host hoặc leakaccountinfo
|
|
else Chưa đăng nhập
|
|
Django->>Django: Bước 1: Kiểm tra JWT token có trong query params?
|
|
Django->>SSOServer: GET /api/auth/validate-token<br/>Authorization: Bearer <token>
|
|
SSOServer-->>Django: 200 OK + JSON payload
|
|
Django->>Django: Bước 2: Validate payload (message, exp, email, unit_id)
|
|
Django->>Django: Map SSO unit_id → Django unit_id
|
|
Django->>DB: Query ThreatIntelExceptionUser<br/>(username + unit_id)
|
|
DB-->>Django: Exception user record
|
|
Django->>DB: Query Django User table
|
|
DB-->>Django: User object
|
|
Django->>Django: Bước 3: login() với ModelBackend
|
|
Django->>Django: Lưu session (unitID, fullname, sso_authenticated)
|
|
Django-->>User: Redirect tới host hoặc leakaccountinfo
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
### 3.2 Mô Tả Chi Tiết Từng Bước
|
|
|
|
#### **Bước 0 — Kiểm Tra Trạng Thái Phiên Hiện Tại**
|
|
|
|
| Thuộc tính | Giá trị |
|
|
|---|---|
|
|
| View | `portal_auth(request)` |
|
|
| File | `threatintel/views.py` line 53 |
|
|
|
|
```
|
|
GET /cti/portal_auth/?jwt=<token>&host=<redirect_url>
|
|
```
|
|
|
|
Django kiểm tra `request.user.is_authenticated`:
|
|
- **Nếu đã đăng nhập** → Kiểm tra `host` redirect có hợp lệ không → Redirect ngay, **bỏ qua toàn bộ quá trình SSO**.
|
|
- **Nếu chưa đăng nhập** → Tiếp tục luồng SSO.
|
|
|
|
> [!NOTE]
|
|
> Nếu `host` không nằm trong `ALLOWED_HOSTS`, Django trả về `400 Bad Request` để chống **Open Redirect Attack**.
|
|
|
|
---
|
|
|
|
#### **Bước 1 — Nhận và Kiểm Tra JWT Token**
|
|
|
|
Django lấy `jwt` từ query string:
|
|
```python
|
|
token = request.GET.get("jwt")
|
|
if not token:
|
|
return HttpResponseBadRequest("Missing JWT token")
|
|
```
|
|
|
|
Nếu không có token → Trả `400 Bad Request`.
|
|
|
|
---
|
|
|
|
#### **Bước 2 — Gọi SSO Validate Token**
|
|
|
|
Django gửi HTTP request đến SSO Validation Server:
|
|
|
|
```python
|
|
resp = requests.get(
|
|
SSO_VALIDATE_URL, # https://10.144.47.157/api/auth/validate-token
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=SSO_TIMEOUT, # 10 giây
|
|
verify=False # Bỏ qua SSL verification (self-signed cert)
|
|
)
|
|
```
|
|
|
|
**Kiểm tra kết quả:**
|
|
|
|
| Kết quả | Hành động |
|
|
|---|---|
|
|
| `requests.RequestException` (timeout, unreachable) | `503 Service Unavailable` |
|
|
| HTTP status ≠ 200 | `401 Unauthorized` - "Invalid or expired token" |
|
|
| JSON parse lỗi | `502 Bad Gateway` - "Invalid SSO response format" |
|
|
| `payload.message` ≠ `"success"` | `401 Unauthorized` - "SSO authentication failed" |
|
|
| `data.exp` < thời gian hiện tại | `401 Unauthorized` - "Token expired" |
|
|
| `data.email` bị thiếu | `401 Unauthorized` - "Missing email in token" |
|
|
|
|
**Cấu trúc JSON response mong đợi từ SSO Server:**
|
|
```json
|
|
{
|
|
"message": "success",
|
|
"data": {
|
|
"email": "nguoidung@vnptmedia.vn",
|
|
"fullname": "Nguyễn Văn A",
|
|
"unit_id": 2,
|
|
"exp": 1745000000
|
|
}
|
|
}
|
|
```
|
|
|
|
Django tách username từ email:
|
|
```python
|
|
username = email.split('@')[0] # "nguoidung"
|
|
```
|
|
|
|
---
|
|
|
|
#### **Bước 3 — Mapping Unit ID**
|
|
|
|
SSO và Django dùng hệ thống `unit_id` riêng biệt. Django thực hiện ánh xạ:
|
|
|
|
| SSO unit_id | Django unit_id | Đơn vị |
|
|
|---|---|---|
|
|
| 1 | 1 | ANTT (Admin) |
|
|
| 2 | 4 | Fintech |
|
|
| 3 | 5 | MyTV |
|
|
| 4 | 3 | VAS |
|
|
| 5 | 2 | TSC |
|
|
| 6 | 6 | MyVNPT |
|
|
|
|
```python
|
|
django_unit_id = UNIT_ID_MAP.get(sso_unit_id)
|
|
if not django_unit_id:
|
|
return JsonResponse({"error": f"SSO unit_id {sso_unit_id} not authorized"}, status=403)
|
|
```
|
|
|
|
Nếu `sso_unit_id` không có trong bảng mapping → `403 Forbidden`.
|
|
|
|
---
|
|
|
|
#### **Bước 4 — Kiểm Tra Bảng Exception Users**
|
|
|
|
Django kiểm tra user có tồn tại trong bảng `threatintel_exception_users` với cặp `(username, unit_id)` khớp:
|
|
|
|
```python
|
|
exception_user = ThreatIntelExceptionUser.objects.filter(
|
|
username=username,
|
|
unit_id=django_unit_id
|
|
).first()
|
|
|
|
if not exception_user:
|
|
return JsonResponse({"error": "User not found in exception users table"}, status=403)
|
|
```
|
|
|
|
**Mô hình dữ liệu:**
|
|
```python
|
|
class ThreatIntelExceptionUser(models.Model):
|
|
username = models.CharField(max_length=255, primary_key=True)
|
|
unit_id = models.IntegerField()
|
|
|
|
class Meta:
|
|
db_table = 'threatintel_exception_users'
|
|
managed = False # Bảng được quản lý bên ngoài Django
|
|
```
|
|
|
|
> [!IMPORTANT]
|
|
> Bảng `threatintel_exception_users` là **danh sách trắng (whitelist)**. Chỉ những user được phép truy cập hệ thống mới có record trong bảng này. Đây là lớp kiểm soát truy cập thứ hai sau SSO.
|
|
|
|
---
|
|
|
|
#### **Bước 5 — Xác Minh User Trong Django**
|
|
|
|
Sau khi kiểm tra exception table, Django xác minh user có tồn tại và đang hoạt động:
|
|
|
|
```python
|
|
try:
|
|
user = User.objects.get(username=username)
|
|
except User.DoesNotExist:
|
|
return JsonResponse({"error": f"User '{username}' not found in system"}, status=404)
|
|
|
|
if not user.is_active:
|
|
return JsonResponse({"error": f"User '{username}' is inactive"}, status=403)
|
|
```
|
|
|
|
---
|
|
|
|
#### **Bước 6 — Đăng Nhập và Lưu Session**
|
|
|
|
Django thực hiện đăng nhập, **bỏ qua xác thực RADIUS**, sử dụng trực tiếp `ModelBackend`:
|
|
|
|
```python
|
|
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
|
|
|
|
# Lưu thông tin vào session
|
|
request.session["unitID"] = str(django_unit_id)
|
|
request.session["sso_unit_id"] = sso_unit_id
|
|
request.session["fullname"] = data.get("fullname", "")
|
|
request.session["sso_authenticated"] = True # Đánh dấu đăng nhập qua SSO
|
|
```
|
|
|
|
**Cấu hình session (settings.py):**
|
|
```python
|
|
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
|
|
SESSION_COOKIE_AGE = 1800 # 30 phút
|
|
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
|
SESSION_SAVE_EVERY_REQUEST = True
|
|
SESSION_COOKIE_HTTPONLY = True
|
|
```
|
|
|
|
---
|
|
|
|
#### **Bước 7 — Signal Logging**
|
|
|
|
Sau khi `login()` được gọi, Django tự động phát ra signal `user_logged_in`, được bắt bởi `signals.py`:
|
|
|
|
```python
|
|
@receiver(user_logged_in)
|
|
def log_login(sender, request, user, **kwargs):
|
|
ip = get_client_ip(request) # Lấy IP thực từ X-Forwarded-For
|
|
ua = request.META.get('HTTP_USER_AGENT', '')
|
|
login_logger.info(f"User '{user.username}' logged in from IP: {ip}, UA: {ua}")
|
|
```
|
|
|
|
**Lấy IP qua Reverse Proxy:**
|
|
```python
|
|
def get_client_ip(request):
|
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
|
if x_forwarded_for:
|
|
return x_forwarded_for.split(',')[0] # IP đầu tiên là IP thực của client
|
|
return request.META.get('REMOTE_ADDR')
|
|
```
|
|
|
|
Log được ghi vào: `logs/authenticate.log` (rotate hàng ngày, giữ tối đa 365 file).
|
|
|
|
---
|
|
|
|
#### **Bước 8 — Redirect Cuối**
|
|
|
|
```python
|
|
return redirect(host or "leakaccountinfo")
|
|
```
|
|
|
|
- Nếu có `host` trong query params → Redirect đến URL đó (sau khi đã validate).
|
|
- Nếu không có `host` → Redirect đến view `leakaccountinfo` (trang chính).
|
|
|
|
---
|
|
|
|
## 4. Cấu Hình Liên Quan
|
|
|
|
### 4.1 Nginx Proxy (default.conf)
|
|
|
|
```nginx
|
|
server {
|
|
listen 80;
|
|
server_name 10.144.13.213 sec.vnptmedia.vn;
|
|
return 301 https://$host/cti; # Redirect HTTP → HTTPS
|
|
}
|
|
|
|
server {
|
|
listen 443 ssl;
|
|
server_name 10.144.13.213 sec.vnptmedia.vn;
|
|
|
|
ssl_certificate /etc/ssl/certs/ctip.crt;
|
|
ssl_certificate_key /etc/ssl/certs/ctip.key;
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
client_max_body_size 4G;
|
|
|
|
location /cti {
|
|
limit_req zone=ip burst=12 delay=8;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header Host $http_host;
|
|
proxy_pass http://192.168.100.100:8001/cti;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.2 Django Settings Liên Quan
|
|
|
|
```python
|
|
# Nhận biết HTTPS qua Reverse Proxy
|
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
|
USE_X_FORWARDED_HOST = True
|
|
USE_X_FORWARDED_PORT = True
|
|
|
|
# SSO Configuration
|
|
SSO_VALIDATE_URL = 'https://10.144.47.157/api/auth/validate-token'
|
|
SSO_TIMEOUT = 10 # giây
|
|
|
|
# Authentication Backends
|
|
AUTHENTICATION_BACKENDS = [
|
|
'axes.backends.AxesStandaloneBackend', # Chống brute-force
|
|
'threatintel.backends.RemoteFreeRADIUSBackend', # RADIUS (cho login thông thường)
|
|
'django.contrib.auth.backends.ModelBackend', # Django default (cho SSO)
|
|
]
|
|
```
|
|
|
|
### 4.3 Environment Variables (.env)
|
|
|
|
```ini
|
|
SSO_VALIDATE_URL = 'https://10.144.47.157/api/auth/validate-token'
|
|
SSO_TIMEOUT = 10
|
|
FREERADIUS_SERVER = 10.144.13.184
|
|
FREERADIUS_SECRET = 'vNpt@1234'
|
|
```
|
|
|
|
---
|
|
|
|
## 5. URL Endpoint
|
|
|
|
| URL | Method | Decorator | Mô tả |
|
|
|---|---|---|---|
|
|
| `/cti/portal_auth/` | `GET` | `@csrf_exempt` | Entry point SSO authentication |
|
|
| `/cti/login/` | `GET/POST` | — | Trang đăng nhập RADIUS thông thường |
|
|
| `/cti/logout/` | `GET` | `@login_required` | Đăng xuất |
|
|
| `/cti/leakaccountinfo/` | `GET/POST` | `@login_required` | Trang chính sau đăng nhập |
|
|
|
|
> [!NOTE]
|
|
> `portal_auth` được đánh dấu `@csrf_exempt` vì request đến từ SSO Portal bên ngoài không mang CSRF token của ứng dụng. Việc bảo mật được thực hiện thông qua JWT token validation thay thế.
|
|
|
|
---
|
|
|
|
## 6. Phân Tích Bảo Mật
|
|
|
|
### 6.1 Các Lớp Bảo Vệ
|
|
|
|
```
|
|
Layer 1: HTTPS/TLS (Nginx) — Mã hóa transport
|
|
Layer 2: JWT Token Validation (SSO Server) — Xác thực danh tính
|
|
Layer 3: Token Expiration Check — Chống replay attack
|
|
Layer 4: Unit ID Authorization — Kiểm soát phân quyền tổ chức
|
|
Layer 5: Exception User Whitelist — Kiểm soát danh sách trắng
|
|
Layer 6: Django User Active Check — Kiểm tra trạng thái tài khoản
|
|
Layer 7: Open Redirect Protection — Chống redirect độc hại
|
|
Layer 8: Rate Limiting (Nginx) — Chống tấn công brute-force
|
|
Layer 9: Django Axes — Chống brute-force đăng nhập thường
|
|
```
|
|
|
|
### 6.2 Điểm Cần Lưu Ý
|
|
|
|
> [!WARNING]
|
|
> `verify=False` trong lệnh gọi SSO Validate URL bỏ qua kiểm tra SSL certificate của SSO Server (do server dùng self-signed cert). Cần cân nhắc thêm CA certificate vào trust store trong môi trường production thực tế.
|
|
|
|
> [!WARNING]
|
|
> Bảng `threatintel_exception_users` được khai báo với `managed = False`, nghĩa là Django **không tự tạo/migrate** bảng này. Bảng phải được tạo và duy trì thủ công trong MySQL.
|
|
|
|
---
|
|
|
|
## 7. Xử Lý Lỗi Tổng Hợp
|
|
|
|
| Tình huống | HTTP Status | Thông báo lỗi |
|
|
|---|---|---|
|
|
| `host` redirect không hợp lệ | `400` | "Invalid redirect URL" |
|
|
| Thiếu JWT token | `400` | "Missing JWT token" |
|
|
| SSO Server không phản hồi | `503` | "SSO service unreachable: ..." |
|
|
| Token không hợp lệ / đã hết hạn (HTTP) | `401` | "Invalid or expired token" |
|
|
| SSO trả JSON lỗi format | `502` | "Invalid SSO response format" |
|
|
| SSO trả `message` ≠ success | `401` | "SSO authentication failed" |
|
|
| Token đã hết hạn (exp check) | `401` | "Token expired" |
|
|
| Thiếu email trong token | `401` | "Missing email in token" |
|
|
| unit_id không được ánh xạ | `403` | "SSO unit_id X not authorized for this system" |
|
|
| User không có trong exception table | `403` | "User 'X' with unit_id Y not found in exception users table" |
|
|
| User không tồn tại trong Django | `404` | "User 'X' not found in system" |
|
|
| User bị vô hiệu hóa | `403` | "User 'X' is inactive" |
|
|
| Lỗi không xác định | `500` | "Authentication failed: ..." |
|
|
|
|
---
|
|
|
|
## 8. So Sánh Hai Luồng Xác Thực
|
|
|
|
| Tiêu chí | RADIUS Login | SSO Portal Auth |
|
|
|---|---|---|
|
|
| **Entry point** | `POST /cti/login/` | `GET /cti/portal_auth/` |
|
|
| **Phương thức xác thực** | Username/Password → FreeRADIUS | JWT Token → SSO Validate API |
|
|
| **Backend Django** | `RemoteFreeRADIUSBackend` | `ModelBackend` (trực tiếp) |
|
|
| **CSRF** | Bắt buộc | Exempt (`@csrf_exempt`) |
|
|
| **Thông tin session** | `unitID` | `unitID`, `sso_unit_id`, `fullname`, `sso_authenticated=True` |
|
|
| **Kiểm tra whitelist** | Có (exception_users) | Có (exception_users) |
|
|
| **Brute-force protection** | Django Axes + RADIUS timeout | Không áp dụng (token-based) |
|
|
| **Khi đã authenticated** | Redirect về leakaccountinfo | Redirect về host hoặc leakaccountinfo |
|
|
|
|
---
|
|
|
|
## 9. Sơ Đồ Quyết Định (Decision Tree)
|
|
|
|
```
|
|
GET /cti/portal_auth/?jwt=TOKEN&host=URL
|
|
│
|
|
▼
|
|
Đã authenticated?
|
|
┌──Yes──┐
|
|
│ │
|
|
▼ ▼
|
|
host hợp lệ? Redirect leakaccountinfo
|
|
│
|
|
No → 400 Bad Request
|
|
│
|
|
Yes → Redirect host
|
|
│
|
|
No (chưa auth)
|
|
│
|
|
▼
|
|
jwt có trong params?
|
|
│
|
|
No → 400 Missing JWT
|
|
│
|
|
Yes
|
|
▼
|
|
host hợp lệ? (nếu có)
|
|
│
|
|
No → 400 Invalid redirect
|
|
│
|
|
Yes/Không có host
|
|
▼
|
|
Gọi SSO Validate API
|
|
│
|
|
Lỗi kết nối → 503
|
|
│
|
|
HTTP != 200 → 401
|
|
│
|
|
JSON lỗi → 502
|
|
│
|
|
message != success → 401
|
|
│
|
|
exp expired → 401
|
|
│
|
|
email thiếu → 401
|
|
│
|
|
▼
|
|
Map unit_id?
|
|
│
|
|
Không có mapping → 403
|
|
│
|
|
Có mapping
|
|
▼
|
|
Trong exception_users?
|
|
│
|
|
Không → 403
|
|
│
|
|
Có
|
|
▼
|
|
User tồn tại trong Django?
|
|
│
|
|
Không → 404
|
|
│
|
|
Có
|
|
▼
|
|
User đang active?
|
|
│
|
|
Không → 403
|
|
│
|
|
Có
|
|
▼
|
|
login() với ModelBackend
|
|
Lưu session
|
|
Signal log
|
|
│
|
|
▼
|
|
Redirect host hoặc leakaccountinfo ✅
|
|
```
|