Bảo mật API — GIW POC Identity Platform
Trạng thái: POC · Ngày: 21/09/2026
Tài liệu này nêu những gì đã làm, và — hữu ích hơn cho người review — những gì chưa.
DIRECT GRANT / PASSWORD GRANT = BẢN TRIỂN KHAI CHỈ DÀNH CHO POC. Không phải kiến trúc production, không phải luồng Galaxy ID production, không phải khuyến nghị xác thực nhân viên cho production. Đường browser / Authorization Code + PKCE được giữ lại làm PRODUCTION CANDIDATE. So sánh đầy đủ và lộ trình chuyển đổi:
../docs/AUTH-STRATEGY.md.
1. Lựa chọn grant — đọc phần này trước
Portal tự render giao diện của nó và gọi Galaxy ID qua token API. Không có redirect trình duyệt và không có trang nào do Keycloak render. Đó là quyết định có chủ đích với cái giá thật; người review nên chấp nhận hoặc bác bỏ nó một cách tường minh, thay vì phát hiện ra khi đọc code.
| Thuộc tính | Giá trị |
|---|---|
| Grant, khách hàng | password (RFC 6749 §4.3) |
| Grant, nhân viên | password + employeeId + citizenId, định tuyến tới một direct-grant authenticator tuỳ biến |
| Grant, đăng ký | client_credentials cho một service account, rồi password |
| Loại client | Confidential, client secret giữ phía server |
| PKCE | Không áp dụng — không có authorization code nào |
| Implicit / hybrid | Đã tắt |
| Standard (redirect) flow | Đã tắt trên cả hai API client |
| Service account | Chỉ bật trên wifi-portal-api, giới hạn ở manage-users + view-users |
Vì sao
Captive portal là nơi duy nhất mà luồng redirect thật sự vỡ. Captive network assistant — trình duyệt mini mà iOS và macOS bật lên trên Wi-Fi captive — xử lý redirect sang origin bên thứ ba rất tệ và không giữ cookie đáng tin. Một lượt đăng nhập phụ thuộc vào redirect sẽ hỏng đúng ở nơi sản phẩm này sống.
Cái giá phải trả
| Mất gì | Hệ quả |
|---|---|
| SSO giữa các sản phẩm Galaxy | Mỗi lần đăng nhập portal là độc lập |
| MFA / xác thực step-up | Password grant không mang cơ chế thử thách nào (T-MFA) |
| Liên kết IdP — doanh nghiệp, mạng xã hội | Sẽ phải thêm lại một đường redirect |
| Cô lập credential | Portal giờ xử lý mật khẩu dạng thô và nằm trong phạm vi audit về việc đó |
Không viện dẫn ngoại lệ tiêu chuẩn nào ở đây. RFC 9700 (OAuth 2.0 Security BCP) nói password grant MUST NOT be used, và OAuth 2.1 đã loại bỏ nó. Không tài liệu nào trong hai tài liệu đó có ngoại lệ cho first-party, và POC này không viện dẫn ngoại lệ nào.
Direct Grant chỉ được dùng như một giải pháp tạm thời có giới hạn cho POC, nhằm kiểm chứng trải nghiệm captive portal. Kiến trúc xác thực cho production vẫn TBC và nên ưu tiên OIDC/federation chạy trên trình duyệt khi điều kiện vận hành cho phép.
Theo dõi ở T-GRANT. Nếu sau này bắt buộc phải liên kết IdP doanh nghiệp cho nhân viên — điều mà ADR-003 khuyến nghị thay cho CCCD — thì đường redirect phải quay lại cho hành trình đó.
Confidential, không phải public
Một public client bật Direct Access Grants cho phép bất kỳ ai trên mạng phát lại grant mà hoàn toàn không cần xác thực client. Portal là ứng dụng phía server nên nó giữ được secret, và nó có giữ. Secret do bootstrap áp từ OIDC_API_CLIENT_SECRET và không bao giờ được ghi vào realm-export.json.
Mode B được giữ lại, không xoá
wifi-portal và wifi-portal-employee — public client, bắt buộc PKCE S256, standard flow bật, Direct Grant tắt — vẫn nằm trong realm cùng flow employee-browser và authenticator giw-employee-verify. Không phần nào của POC gọi chúng.
Chúng được giữ vì Authorization Code + PKCE là đường mà một Enterprise IdP liên kết vào, và ADR-003 khuyến nghị đúng điều đó thay cho CCCD. Năm test tự động khẳng định chúng vẫn chạy, nên đường này không mục đi mà không ai biết.
Token không bao giờ tới trình duyệt
Token sống trong một bản đồ phía server. Trình duyệt giữ một cookie mờ sid: HttpOnly, SameSite=Strict, Max-Age=3600. Bản đồ được dọn mỗi 5 phút nên không phình vô hạn.
SameSite=Strict, không phải Lax: portal cố ý không có điểm vào cross-site nào, nên không có gì để nới lỏng.
2. Verify token
Entitlement Service verify đầy đủ trước khi đọc dù chỉ một claim:
| Kiểm tra | Cách làm |
|---|---|
| Thuật toán | Danh sách cho phép RS256/RS384/RS512. none và HMAC bị từ chối thẳng. |
| Chữ ký | crypto.verify với khoá JWKS khớp kid của token |
| Xoay khoá | JWKS cache 5 phút; gặp kid lạ thì buộc lấy lại ngay một lần |
iss |
Khớp chính xác với issuer đã cấu hình |
aud |
Phải giao với danh sách audience đã cấu hình; lùi về azp khi aud quá mỏng |
exp |
Từ chối khi đã qua, dung sai lệch đồng hồ ±30 s |
nbf |
Từ chối khi còn ở tương lai, ±30 s |
Danh tính lấy từ token, không bao giờ từ body. subjectId trong body mâu thuẫn với sub sẽ trả 403; userType mâu thuẫn thì ghi log và bỏ qua. Không có điều này thì portal có thể tự khai entitlement của mình và service chỉ còn là đồ trang trí.
3. Xác thực giữa các service
| Chặng | POC | Đích production |
|---|---|---|
| Keycloak authenticator → HR API | Secret dùng chung X-API-Key, so sánh thời gian hằng số |
mTLS hoặc service token ngắn hạn. Một khoá tĩnh dùng chung là không chấp nhận được với một endpoint nhận CCCD. |
| Portal → Entitlement | Bearer token của người dùng cuối | Giữ nguyên. Có thể thêm client credential cho chính portal. |
| Portal → Session | Secret dùng chung X-API-Key |
Service token với audience là giw-session |
| Entitlement → JWKS | Không có (khoá công khai) | Giữ nguyên |
Khoá API của HR không bao giờ được ghi vào realm-export.json. Nó đến Keycloak qua biến môi trường GIW_HR_API_KEY, nên repository không mang secret nào. .env đã git-ignore; .env.example chỉ chứa giá trị giữ chỗ.
4. Đường truyền
| Môi trường | Đường truyền |
|---|---|
| POC trên localhost | HTTP thuần. sslRequired: none trên realm. |
| Mọi nơi khác | Bắt buộc HTTPS. Đặt sslRequired: external, KC_HOSTNAME là origin https, và thêm Secure vào cookie sid của portal. |
Đưa cấu hình đường truyền của POC này lên một môi trường dùng chung là đặt một số CCCD lên dây dưới dạng văn bản rõ.
5. Chống brute force và giới hạn tần suất
| Bề mặt | Biện pháp | Giá trị |
|---|---|---|
| Đăng nhập mật khẩu Keycloak | Brute-force protection của realm | 5 lần thất bại, thời gian chờ tăng dần tới 900 s, không khoá vĩnh viễn |
| Form xác minh nhân viên (browser flow) | Bộ đếm theo từng phiên xác thực | 5 lần submit rồi ACCESS_DENIED |
| Xác minh nhân viên (API flow) | không có ở portal | Khoảng trống — direct grant không có bộ đếm theo phiên |
| Bùng phát quick-login | quickLoginCheckMilliSeconds của realm |
2 lần thất bại trong 200 ms → giữ 60 s. Trước là 1000 ms, khiến một hành khách bấm Đăng nhập hai lần liên tiếp bị khoá. |
| HR verify API | Bộ đếm trượt theo từng peer | 30 request / 60 s → 429 RATE_LIMITED |
| Entitlement Service | không có | Khoảng trống |
| Session Service | không có | Khoảng trống |
Bộ đếm trên form nhân viên tính theo phiên xác thực, nên lách được bằng cách khởi động lại luồng. Nó làm tăng chi phí của một cuộc tấn công đoán CCCD nhưng không chặn được. Biện pháp cho production phải tính theo employeeId và theo nguồn, cưỡng chế tập trung.
6. Dò tìm tài khoản và credential
Ba lựa chọn thiết kế có chủ đích:
- HR trả về body giống nhau từng byte cho "không có nhân viên này" và "sai CCCD".
- So sánh bằng
crypto.timingSafeEqual, và handler của HR quét mọi bản ghi kể cả sau khi đã khớp, nên thời gian phản hồi không tiết lộ việc có trúng hay không. - Authenticator hiển thị cùng một thông báo (
employeeVerifyFailed) cho cả hai.
Một test khẳng định hai response giống nhau hoàn toàn. Nếu sau này ai đó "cải thiện" thông báo lỗi, test đó sẽ trượt.
7. Chống phát lại
| Hướng tấn công | Biện pháp |
|---|---|
Cookie sid bị đánh cắp |
HttpOnly (script không chạm được), SameSite=Strict (không gửi cross-site), TTL 1 giờ, dọn phía server |
| Phát lại credential | Portal không ngăn được — đó là cái giá của password grant, chỉ giảm nhẹ bằng brute-force protection |
| Entitlement trùng | Idempotency-Key trả lại quyết định gốc |
| Cấp phát session trùng | Idempotency-Key trả lại session gốc |
| Grant / revoke trùng | Khai báo: áp lại cùng một trạng thái mong muốn là vô hại |
| Đăng ký trùng | Keycloak trả 409; portal hiển thị ACCOUNT_EXISTS |
Các biện pháp bảo vệ authorization-code, state và nonce không còn áp dụng: kiến trúc này không có authorization code. Đó là giảm số bộ phận chuyển động, không phải một khoảng trống — nhưng nó cũng lấy đi lớp phòng thủ chiều sâu mà các cơ chế đó mang lại.
8. Secret
| Secret | Ở đâu | Có bị commit không? |
|---|---|---|
HR_API_KEY |
.env, biến môi trường container, GIW_HR_API_KEY |
Không — .env đã git-ignore |
SESSION_API_KEY |
.env, biến môi trường container |
Không |
KC_ADMIN_PASSWORD |
.env, biến môi trường container |
Không |
| Mật khẩu Postgres | .env, biến môi trường container |
Không |
| OIDC client secret | Không tồn tại — là public client | không áp dụng |
.env.example chứa giá trị giữ chỗ có đánh dấu change-me. Chúng cố ý yếu để stack khởi động được mà không cần can thiệp, và phải được thay trước khi stack rời khỏi máy tính cá nhân.
9. Audit
| Sự kiện | Ở đâu |
|---|---|
| Đăng nhập thành công / thất bại, đăng xuất, đăng ký, đổi token | Realm event của Keycloak (eventsEnabled, hết hạn sau 24 h) |
| Kết quả xác minh nhân viên | Log của authenticator + event Keycloak với giw_correlation_id, giw_employee_ref |
| Kết quả xác minh ở HR | Log có cấu trúc của HR: correlationId, employeeId, employeeRef, active, wifiEligible |
| Cấp / từ chối entitlement | Log của Entitlement: sub, entitlementId, entitlementType, tier, hoặc lý do từ chối |
| Session được tạo / grant / revoke | Log của Session kèm sessionId và entitlementId |
Mọi log đều là JSON một dòng có correlationId, nên một hành trình là một lệnh grep.
10. Header bảo mật
Response của portal mang X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cache-Control: no-store. Keycloak tự đặt header của nó.
Chưa đặt: Content-Security-Policy trên các trang của portal, và Strict-Transport-Security (vô nghĩa trên HTTP thuần).
11. Khoảng trống đã biết — đọc phần này trước khi duyệt bất cứ điều gì
| # | Khoảng trống | Mức độ |
|---|---|---|
| 1 | HTTP thuần; ra khỏi localhost thì CCCD sẽ đi qua mạng dưới dạng văn bản rõ | Nghiêm trọng khi ra khỏi localhost |
| 2 | Xác thực với HR là một khoá tĩnh dùng chung | Cao |
| 3 | Không có giới hạn tần suất trên Entitlement hay Session, và trên chính API xác thực của portal | Cao — portal giờ là cửa trước hứng tấn công nhồi credential |
| 3b | Luồng nhân viên qua API không có bộ đếm số lần thử (browser flow thì có) | Cao — tấn công đoán CCCD ở đây rẻ hơn |
| 3c | Password grant: không MFA, không SSO, không federation (T-GRANT, T-MFA) | Trung bình, theo thiết kế, cần ký duyệt tường minh |
| 4 | Chấp nhận aud: account — bất kỳ token nào trong realm cũng qua được kiểm tra audience |
Trung bình (T-AUD) |
| 5 | Trạng thái entitlement và session nằm trong bộ nhớ; restart là mất hết | Trung bình với POC, chặn với production |
| 6 | đã xử lý | |
| 7 | Chưa có CSP trên các trang của portal | Thấp |
| 8 | Bộ đếm brute-force trên form nhân viên tính theo phiên nên lách được | Trung bình |
| 9 | POST /admin/fault trên HR mock hoàn toàn không xác thực |
Nghiêm trọng nếu từng được triển khai — đó là công cụ test và phải bị xoá, không phải đi bảo vệ nó |
| 10 | Không thu thập bằng chứng đồng ý nào cho đường CCCD | Cao — Luật 91/2025 |
11b. Ranh giới bảo mật — ai biết cái gì
| Bên | Biết gì | Giữ gì | Không bao giờ |
|---|---|---|---|
| Trình duyệt | Host của portal, không gì khác | Một cookie mờ sid — HttpOnly, SameSite=Strict, 1 giờ |
Access / refresh / ID token · endpoint của Keycloak, HR, Entitlement hay Session |
| Portal / BFF | Mọi endpoint phía sau | Token, chỉ phía server, TTL 1 giờ, dọn mỗi 5 phút | Lưu mật khẩu · lưu CCCD · ghi log một trong hai · quyết định việc xác thực hay entitlement |
| Keycloak | Credential trong lúc xác minh | Danh tính, phiên | Mở mạng · quyết định entitlement |
| HR API | Tình trạng lao động | Không gì — chỉ phán quyết, không trạng thái | Trả về hồ sơ · là hệ gốc của danh tính |
| Entitlement | Các claim đã được verify | Quyết định | Thực thi · tạo phiên |
| Session | Trạng thái phiên | Trạng thái truy cập | Quyết định — grant mà thiếu entitlementId là 422 |
Về câu "xoá giá trị nhạy cảm ngay sau request"
Chuỗi trong JavaScript là bất biến. Một mật khẩu hay CCCD đọc từ body của request không thể bị ghi đè bằng 0 — không có memset nào. Thực tế bảo đảm được:
- Giá trị được đọc vào một biến cục bộ và truyền thẳng vào request đi ra.
- Nó không bao giờ được gán vào field, object phiên, cache hay closure sống lâu hơn request.
- Nó ra khỏi scope khi handler trả về, để bộ thu gom rác xử lý.
- Không lệnh log nào trong portal nhận nó làm tham số.
Điểm 2–4 cưỡng chế được và đang được cưỡng chế, riêng điểm 4 có test tự động. Điểm 1 không phải là xoá sạch và tài liệu này sẽ không giả vờ ngược lại. Một ngôn ngữ kiểm soát được bộ nhớ sẽ làm tốt hơn — thêm một lý do nữa để Mode A chỉ dành cho POC.
Bên trong Keycloak cũng vậy: EmployeeVerification giữ CCCD trong một biến cục bộ, đưa cho HrVerificationClient, và không bao giờ ghi nó vào field, user attribute, auth-session note, chi tiết event hay dòng log. Khi lỗi, HrVerificationClient chỉ log e.getClass().getSimpleName(), không bao giờ e.getMessage(), vì một số tầng transport vọng nguyên body của request vào thông báo exception.
12. CCCD — phản biện thường trực
POC này triển khai mã nhân viên + CCCD vì task order yêu cầu, và nó khoanh vùng dữ liệu chặt hết mức thiết kế cho phép: thoáng qua, không log, không lưu, không đưa vào token, với test tự động cưỡng chế cả bốn điều đó.
Đó là khoanh vùng, không phải tán thành. ADR-003 và quyết định D3 hiện đều nói: bỏ CCCD khỏi việc xác minh nhân viên và dùng liên kết IdP doanh nghiệp (OIDC) với OTP qua email công ty làm phương án dự phòng. Không điều gì trong POC này thay đổi khuyến nghị đó.
Mã nhân viên + CCCD = cách xác minh cho POC. Production: TBC — cần Legal + Security + HR review.