Vấn đề
TTMI có nhiều web app nội bộ phục vụ ~500 nhân viên ở 4 thương hiệu, cùng dùng chung một nguồn định danh. Tôi viết các phần phía client mô tả dưới đây. Các app nhận phiên và phần kiểm tra phía server nằm ở codebase khác.
Có ba lỗ hổng.
Nhân viên đã nghỉ việc vẫn dùng được tab đang mở cho tới khi token hết hạn.
Chuyển từ app nội bộ này sang app anh em phải đăng nhập lại.
Một khu vực tài chính mới cần role và phạm vi dữ liệu riêng, trong khi các màn hình người dùng không được xem vẫn chạy query dữ liệu ngầm.
Tôi đã làm gì
Kiểm tra trạng thái ngay trong request interceptor
Trước khi gửi một API request thông thường, client kiểm tra trạng thái làm việc của người dùng. Kết quả hợp lệ được cache trong thời gian ngắn, và các lượt kiểm tra đồng thời dùng chung một request đang chạy. Khi người dùng focus vào tab, quay lại tab, hoặc phiên thay đổi ở tab khác, client buộc kiểm tra lại. Nếu người dùng đã nghỉ, client xóa phiên và chuyển về trang đăng nhập.
Kiểm tra ở mọi request sẽ thêm một round trip cho mọi thứ. Cache để lại một khoảng hở ngắn, và tôi đã ghi rõ trong tài liệu. Tôi chấp nhận vì server vẫn là nơi quyết định, còn lớp bảo vệ này có nhiệm vụ đóng nhanh một tab đang mở.
Fail closed khi không rõ trạng thái, giữ phiên khi lỗi mạng
Trạng thái không xác định hoặc đã nghỉ việc sẽ kết thúc phiên. Lỗi mạng thì không, vì kết nối chập chờn không chứng minh ai đó đã nghỉ, và đăng xuất người dùng mỗi lần mạng chập chờn thì tệ cho tất cả mọi người. Lượt kiểm tra trạng thái cũng đi qua một HTTP path riêng, để interceptor không tự gọi lại chính nó thành vòng lặp. Tôi viết chính sách xử lý lỗi này ra giấy trước khi viết code.
Chuyển phiên đăng nhập giữa các app mà không để token trên URL
App nguồn mở một app đích nằm trong allow-list. Hai cửa sổ thực hiện một handshake qua window messaging, trong đó app đích báo sẵn sàng kèm một nonce dùng một lần. App nguồn kiểm tra origin của message, cửa sổ gửi và nonce, xác nhận lại phiên của chính nó vẫn còn hiệu lực, rồi mới chuyển phiên sang. Không có gì nhạy cảm xuất hiện trên URL, nên không thể lộ qua lịch sử trình duyệt hay log. Timeout và popup bị chặn đều có thông báo rõ ràng, và đăng nhập trực tiếp vẫn là phương án dự phòng.
Đổi lại, thu hồi phiên ở app nguồn cũng kết thúc mọi phiên được tạo ra từ nó. Tôi chấp nhận và ghi vào tài liệu, vì đó đúng là cách thu hồi quyền nên hoạt động.
Permission gate theo kiểu fail closed
Route mà người dùng không có quyền sẽ không bao giờ mount các query dữ liệu, nên không có request nào lấy dữ liệu người dùng không được xem. Menu điều hướng ẩn mục đó, và role cao ở khu vực này không vượt được quyền của khu vực khác. Các kiểm tra này phản chiếu phân quyền phía server để trải nghiệm gọn hơn, và không bao giờ thay thế nó.
Kết quả
Các lớp bảo vệ đã chạy production cho mọi nhóm nhân viên. Nhân viên nghỉ việc mất quyền truy cập ở thao tác kế tiếp, không phải chờ token hết hạn. Người dùng chuyển giữa các app anh em bằng một cú nhấp, và không token nào xuất hiện trên URL. Màn hình bị từ chối hiển thị trạng thái “không có quyền” mà không tải dữ liệu.
Contract test bằng Vitest phủ các trường hợp bị từ chối, lỗi và biên của những lớp bảo vệ này.
Nếu làm lại
Tôi sẽ thống nhất chính sách xử lý lỗi với các bạn phụ trách backend sớm hơn, dưới dạng một contract viết chung, vì định nghĩa “cái gì fail closed” ở client và server phải khớp nhau. Tôi cũng sẽ thêm một test end-to-end nhỏ chạy trên hai origin thật, vì unit test chỉ giả lập được handshake giữa các cửa sổ.