SSO giữa hai ứng dụng Laravel: Chuyển phiên đăng nhập bằng token mã hóa mà không bắt người dùng đăng nhập lại
Hai ứng dụng Laravel có thể chuyển trạng thái đăng nhập cho nhau bằng một token ngắn hạn được mã hóa rồi xác thực ở ứng dụng đích. Phần khó không nằm ở Auth::login(), mà ở quản lý khóa, thời hạn token, chống replay và đồng bộ logout.
Một người dùng đã đăng nhập ở client.example.com nhưng khi chuyển sang partner.example.com lại phải nhập mật khẩu lần nữa. Với hai ứng dụng Laravel do cùng một hệ thống quản lý, có thể giải quyết tình huống này bằng một luồng Single Sign-On (SSO) đơn giản: ứng dụng đang có phiên đăng nhập tạo một thông điệp xác thực ngắn hạn, chuyển nó sang ứng dụng còn lại, rồi ứng dụng đích xác minh thông điệp trước khi tạo session cục bộ cho người dùng.
Cách tiếp cận này dựa trên ý tưởng trong bài Implementing SSO Login for Two Different Sites in Laravel của Amit Saha, nhưng có một điểm cần làm rõ khi triển khai thực tế: Laravel Crypt mặc định dùng APP_KEY của ứng dụng. Nếu hai project có APP_KEY khác nhau, chúng không thể chỉ dùng Crypt facade mặc định rồi mong giải mã được dữ liệu của nhau. Với SSO tự triển khai, nên tách một khóa chuyên dụng cho SSO thay vì dùng chung APP_KEY giữa hai ứng dụng.
Luồng SSO giữa hai ứng dụng hoạt động như thế nào?
Giả sử hệ thống có hai ứng dụng Laravel độc lập: Client Panel tại client.example.com và Partner Panel tại partner.example.com. Cả hai cùng nhận diện một tập người dùng, có thể qua cùng cơ sở dữ liệu hoặc qua cơ chế đồng bộ user riêng.
- Người dùng đăng nhập bình thường ở Client Panel.
- Khi họ chọn sang Partner Panel, Client Panel tạo một SSO payload có thời hạn rất ngắn.
- Payload được mã hóa và xác thực toàn vẹn bằng một khóa SSO mà cả hai ứng dụng cùng biết.
- Trình duyệt được chuyển tới endpoint SSO của Partner Panel.
- Partner Panel giải mã token, kiểm tra thời hạn, ứng dụng đích và khả năng token đã bị dùng lại hay chưa.
- Nếu hợp lệ, Partner Panel tìm user tương ứng, gọi Auth::login() và tạo session riêng của mình.
Điều đáng chú ý là hai website không nhất thiết phải dùng chung session. SSO chỉ giúp ứng dụng thứ hai tin rằng người dùng đã được xác thực ở ứng dụng thứ nhất; sau đó mỗi ứng dụng vẫn có thể duy trì session độc lập.
Không nên dùng chung APP_KEY chỉ để hai app giải mã được token
Tài liệu Laravel hiện tại cho biết dịch vụ mã hóa của framework dùng OpenSSL, hỗ trợ AES-256/AES-128 và gắn MAC để phát hiện dữ liệu bị sửa. Khóa mặc định của Laravel được lấy từ APP_KEY. Vì APP_KEY còn liên quan tới cookie mã hóa và nhiều dữ liệu khác của ứng dụng, chia sẻ cùng một APP_KEY giữa hai project chỉ để làm SSO sẽ tạo ra mức phụ thuộc lớn hơn cần thiết.
Một cách sạch hơn là tạo biến môi trường riêng, chẳng hạn SSO_KEY=base64:..., và cấu hình một Encrypter dành riêng cho luồng SSO ở cả hai project. Khóa này phải có entropy đủ mạnh, được lưu như secret, không commit vào repository và cần có kế hoạch rotate.
Ở mức cấu hình, Client Panel chỉ cần biết URL đích, ví dụ SSO_PARTNER_URL=https://partner.example.com. Partner Panel có thể cấu hình URL quay về tương tự. Cả hai cùng dùng SSO_KEY nhưng vẫn giữ APP_KEY riêng.
Token nên chứa gì?
Bài gốc dùng token mã hóa mang thông tin người dùng. Trong triển khai thực tế, payload nên càng nhỏ càng tốt. Thay vì đưa toàn bộ user record vào token, chỉ cần những trường cần thiết để xác định và kiểm soát phiên SSO.
- sub: định danh ổn định của user, thường là ID nội bộ hoặc UUID.
- iat: thời điểm token được phát hành.
- exp: thời điểm token hết hạn; với token chuyển tiếp đăng nhập, thời gian sống nên ngắn.
- aud: ứng dụng được phép nhận token, ví dụ partner.
- jti: mã ngẫu nhiên duy nhất cho từng token để hỗ trợ chống replay.
Email có thể hữu ích khi hai hệ thống dùng database riêng, nhưng không nên coi email trong token là nguồn dữ liệu duy nhất nếu user có thể đổi email. Một ID ổn định vẫn là lựa chọn tốt hơn.
Sau khi tạo payload, Client Panel mã hóa nó bằng Encrypter SSO rồi chuyển người dùng tới endpoint như https://partner.example.com/sso-login. Với một demo đơn giản, token có thể đi qua query string. Trong production, đây không phải lựa chọn lý tưởng vì URL có thể xuất hiện trong browser history, access log hoặc hệ thống quan sát. An toàn hơn là dùng POST hoặc, tốt hơn nữa, chuyển một authorization code dùng một lần rồi để server của Partner Panel đổi code đó lấy danh tính qua kênh server-to-server.
Ứng dụng đích phải kiểm tra nhiều hơn việc “giải mã được”
Tại Partner Panel, endpoint SSO nhận dữ liệu từ Client Panel và thử giải mã. Nếu giải mã thất bại hoặc MAC không hợp lệ, Laravel sẽ báo lỗi thay vì trả về một payload có thể sử dụng. Nhưng giải mã thành công mới chỉ chứng minh thông điệp được tạo bởi bên có khóa; nó chưa đủ để đăng nhập ngay.
Partner Panel nên kiểm tra ít nhất bốn điều: token chưa hết hạn, aud đúng với ứng dụng hiện tại, jti chưa từng được sử dụng và user được tham chiếu vẫn còn hợp lệ. Có thể lưu jti vào cache trong đúng khoảng thời gian sống của token; thao tác thêm khóa nên có tính nguyên tử để hai request đồng thời không thể cùng dùng lại một token.
Khi mọi kiểm tra đều qua, ứng dụng tìm user từ sub rồi gọi Auth::login($user). Sau khi xác thực thành công, session nên được regenerate để giảm rủi ro session fixation. Laravel cũng hỗ trợ Auth::loginUsingId() nếu hệ thống chỉ cần đăng nhập bằng khóa chính, nhưng vẫn phải đảm bảo ID đó đến từ token đã được xác minh đầy đủ.
Middleware chống replay không phải lớp bảo vệ duy nhất
Bài gốc đề xuất kiểm tra timestamp để giảm nguy cơ replay attack. Timestamp là cần thiết, nhưng một token còn hạn vẫn có thể bị phát lại nhiều lần nếu chỉ kiểm tra thời gian. Vì vậy jti dùng một lần là phần bổ sung quan trọng.
Ngoài ra, endpoint SSO nên chỉ chạy qua HTTPS, giới hạn redirect URL về một danh sách cho phép và tuyệt đối không nhận một URL tùy ý từ query string rồi redirect thẳng. Nếu không có allowlist, luồng đăng nhập rất dễ biến thành open redirect, khiến token hoặc người dùng bị chuyển tới domain không mong muốn.
Logout giữa hai ứng dụng cần một cơ chế riêng
SSO thường khiến người dùng kỳ vọng rằng “đăng xuất một nơi là đăng xuất mọi nơi”, nhưng điều đó không tự xảy ra khi mỗi Laravel app giữ session riêng. Gọi Auth::logout() ở Client Panel chỉ kết thúc phiên của chính Client Panel.
Laravel khuyến nghị sau logout nên invalidate session và regenerate CSRF token. Nếu muốn đăng xuất toàn hệ thống, cần thêm một cơ chế điều phối: một dịch vụ xác thực trung tâm, back-channel logout giữa các ứng dụng, hoặc một danh sách session/token đã bị thu hồi mà các app cùng kiểm tra. Nếu không triển khai phần này, bài viết và giao diện nên nói rõ rằng SSO chỉ áp dụng cho đăng nhập, không phải global logout.
Kiểm thử luồng SSO
- Đăng nhập vào Client Panel bằng một user hợp lệ.
- Chuyển sang Partner Panel và xác nhận không bị hỏi lại mật khẩu.
- Thử sửa một ký tự trong token và kiểm tra request bị từ chối.
- Thử dùng token sau thời điểm exp.
- Thử phát lại cùng token hai lần và xác nhận lần thứ hai bị chặn.
- Thử thay aud để bảo đảm token dành cho một ứng dụng không được dùng ở ứng dụng khác.
- Đăng xuất ở từng app và kiểm tra đúng hành vi mà hệ thống đã thiết kế cho logout.
Đừng bỏ qua các ca lỗi. Một luồng SSO chạy được trong happy path nhưng không kiểm soát replay, redirect hoặc vòng đời session có thể tạo ra vấn đề bảo mật khó phát hiện hơn một màn hình đăng nhập thông thường.
Khi nào nên dùng Passport, Sanctum hoặc một IdP thay vì tự làm?
Cơ chế token mã hóa ở trên phù hợp với một hệ thống nhỏ, first-party, nơi cùng một đội kiểm soát cả hai ứng dụng và yêu cầu SSO tương đối đơn giản. Khi số ứng dụng tăng, có ứng dụng bên thứ ba, cần consent, scope, refresh token, federation hoặc audit chặt chẽ, việc duy trì giao thức riêng sẽ nhanh chóng trở thành gánh nặng.
Laravel Passport cung cấp một OAuth2 server đầy đủ và phù hợp khi hệ thống thực sự cần OAuth2. Laravel Sanctum lại nhắm tới SPA, mobile app và API token; với SPA first-party, Sanctum dùng session cookie thay vì token và yêu cầu frontend/API cùng top-level domain. Đây là hai bài toán khác với việc tự chuyển một SSO token giữa nhiều ứng dụng.
Với hệ thống lớn hơn, lựa chọn thực tế thường là đặt một Identity Provider ở giữa và dùng chuẩn như OpenID Connect hoặc SAML. Khi đó các ứng dụng Laravel không phải tự thống nhất định dạng token, chống replay, rotate khóa và global logout theo cách riêng.
Cách đơn giản trong bài gốc vẫn có giá trị để hiểu bản chất của SSO giữa hai ứng dụng: một bên chứng minh danh tính, bên còn lại xác minh bằng chứng đó rồi tạo session cục bộ. Nhưng để đưa vào production, phần quan trọng nhất không phải vài dòng Auth::login(), mà là thiết kế vòng đời của bằng chứng xác thực và giới hạn những gì một token bị lộ có thể làm được.
Nguồn tham khảo: bài gốc của Amit Saha trên Medium; Laravel 13.x Encryption; Laravel 13.x Authentication; Laravel 13.x Passport.