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:

  1. HR trả về body giống nhau từng byte cho "không có nhân viên này" và "sai CCCD".
  2. 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.
  3. 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 Bản đồ phiên của portal không có cơ chế loại bỏ — đã sửa: TTL 1 giờ, dọn mỗi 5 phút đã 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:

  1. Giá trị được đọc vào một biến cục bộ và truyền thẳng vào request đi ra.
  2. Nó không bao giờ được gán vào field, object phiên, cache hay closure sống lâu hơn request.
  3. Nó ra khỏi scope khi handler trả về, để bộ thu gom rác xử lý.
  4. 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.

GIW POC Identity Platform · bản demo local · không phải production · sinh từ repository