通过 Cookie 实现基于 Session 的单点登录
单点登录及实现思路
单点登录(Single Sign On),简称为 SSO,意思是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的其它应用系统。一般常用于同一家公司的不同子系统之间的登录认证。
单点登录有多种实现方式,这里我们只介绍两种,一种是基于 Cookie 凭证,这种方式适用于子系统之间主域名一致,因为只有这样才能让不同子系统之间共享 Cookie;另一种是通过 CAS 实现 SSO 系统,这种方案适用于所有场景,只是相比较 Cookie 凭证来说理解和实现起来更复杂一些。
我们先从简单的入手,通过 Cookie 凭证来实现简单的单点登录。
具体思路和我们前面讲的单页面应用 API 认证有些类似,只不过现在我们的不同子系统之间是分离的,域名也不一样,比如主系统是 blog.test,子系统是 sub.blog.test。Laravel 添加 Cookie 到响应时默认作用域名是当前系统域名,因此需要对写入 Cookie 的域名做额外设置,才能保证主系统和子系统之间可以共享 Cookie 凭证。
此外,我们让子系统和主系统之间共享用户表,子系统中不需要再设置独立的用户表,子系统获取用户信息时通过 API 从主系统获取,这就需要我们自己实现 UserProvider 以便获取用户信息。
创建一个新的测试应用
首先,我们创建一个新的测试项目 subapp:
composer create-project laravel/laravel subapp --prefer-dist -vvv
并将其域名设置为 sub.blog.test。
登录中心 Cookie 设置
单点登录需要一个独立的登录中心,我们以主系统 blog 作为登录中心,所有子系统登录请求都跳转到这里。
设置 Cookie 域名
我们将主系统 Cookie 作用域名设置为 .blog.test,登录成功后,通过 CreateFreshApiToken 中间件可以将令牌写入到 Cookie,Session ID 也会写入到 Cookie, 这样,在子系统中就可以读取这些 Cookie,并在请求头中带上这些信息。
Cookie 域名设置通过 config/session.php 中的配置项 domain 来实现,我们在 .env 环境配置中设置 SESSION_DOMAIN 即可完成设置:
SESSION_DOMAIN=.blog.test
接下来,我们在 app/Http/Kernel.php 中添加 CreateFreshApiToken 中间件(该中间件需要事先安装过 Passport):
protected $middlewareGroups = [
'web' => [
...
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
],
...
注:如果之前已经配置过,可以跳过这一步。
然后,在 routes/api.php 中新增一条路由,用户根据用户 ID 返回用户信息:
Route::middleware('auth:api')->group(function () {
...
Route::get('/user/{id}', function ($id) {
return \App\User::find($id);
});
});
设置 Session 存储媒介
对于基于 Session 实现的单点登录而言,我们还要让主系统和子系统共享用户 Session 信息,这样在多个系统中都可以读取到用户 Session,从而判断登录状态。我们可以通过将 Session 存储到 Redis 中来实现这一需求,因此,需要在登录中心(主系统)安装 predis 扩展:
composer require predis/predis
然后在 .env 环境配置中修改 SESSION_DRIVER:
SESSION_DRIVER=redis
以及 Redis 相关配置值,以便可以在系统中连接到 Redis。
至此,登录中心(blog应用)已经配置好了,下面我们回到子系统 subapp。
在子系统自定义 UserProvider 实现
回到子系统项目,我们需要自定义一个 UserProvider 实现来从主系统获取认证用户信息,在 app 目录下创建一个 Extensions 子目录,并在该子目录下创建一个 SsoUserProvider 类:
<?php
namespace App\Extensions;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use Illuminate\Auth\EloquentUserProvider;
class SsoUserProvider extends EloquentUserProvider
{
public function retrieveById($identifier)
{
$http = new Client();
$cookies = CookieJar::fromArray([
'laravel_token' => $_COOKIE['laravel_token']
], '.blog.test');
$response = $http->request('GET', 'http://blog.test/api/user/' . $identifier, [
'cookies' => $cookies
]);
$user = json_decode($response->getBody()->getContents(), TRUE);
$model = $this->createModel();
$model->forceFill($user);
return $model;
}
}
在这个自定义的类中,我们通过 API 接口获取用户信息并返回。
然后将 config/auth.php 的 providers 配置项修改如下:
'providers' => [
'users' => [
'driver' => 'sso',
'model' => App\User::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
最后,在 AuthServiceProvider 的 boot 方法中注册自定义的 UserProvider 类使其生效:
// 在文件定义引入以下命名空间
use App\Extensions\SsoUserProvider;
use Illuminate\Support\Facades\Auth;
public function boot()
{
...
// 通过自定义的 EloquentUserProvider 覆盖系统默认的
Auth::provider('sso', function ($app, $config) {
return new SsoUserProvider($app->make('hash'), $config['model']);
});
}
子系统 Cookie 及 Session 相关配置
为了在子系统中获取登录中心用户 Session,需要将子系统 Session 相关配置和登录中心设置为一样,我们在 .env 配置文件中进行配置即可:
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_DOMAIN=.blog.test
同样,也需要在子系统 subapp 中安装 predis 扩展:
composer require predis/predis
然后在 .env 中对 Redis 进行配置以便可以连接,这里的配置需要和登录中心保持一致,才可以顺利读取到用户 Session。
此外,还需要将子系统的 APP_KEY 和登录中心配置成一样的,这样,就可以保证 Cookie 及 Session 加密解密结果的一致性了。
在子系统测试单点登录
在测试单点登录功能之前,在子系统 subapp 中先运行 make:auth 命令生成系统自带认证脚手架代码:
php artisan make:auth
然后我们修改 LoginController 将登录请求重定向到主系统登录中心:
// 将登录页面重定向到登录中心
public function showLoginForm()
{
return redirect('http://blog.test/login');
}
将退出请求也发送到后台登录中心进行退出操作:
// 在文件顶部引入相应的命名空间
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
// 发送退出请求到登录中心,然后清除本地会话
public function logout(Request $request)
{
$http = new Client();
$cookies = CookieJar::fromArray([
'laravel_session' => $_COOKIE['laravel_session'],
'XSRF-TOKEN' => $_COOKIE['XSRF-TOKEN']
], '.blog.test');
$response = $http->request('POST', 'http://blog.test/logout', [
'cookies' => $cookies
]);
if ($response->getStatusCode() == 200) {
$request->session()->invalidate();
return $this->loggedOut($request) ?: redirect('/');
}
abort(500);
}
最后在 RegisterController 中将注册请求也重定向到后台登录中心:
public function showRegistrationForm()
{
return redirect('http://blog.test/register');
}
注:这里我们只测试登录、注册、退出流程,其它认证相关流程需要跳转的请自行处理。
下面我们来测试登录流程,在子系统中访问 http://sub.blog.test/login,页面会跳转到主系统登录中心:

填写登录表单提交登录后,返回子系统,访问 http://sub.blog.test/home,会发现已经登录成功:

在子系统退出之后,访问主系统,发现都已经成功退出。
注册流程也是类似,如果登录中心设置了邮箱验证,在子系统判断用户是否认证的时候,也要启用邮箱验证中间件。
另外,这里有一个细节,就是从子系统跳转到登录中心登录成功后,需要跳转会相应的子系统页面,这可以通过在 Session 中设置回跳地址来实现,即在登录中心登录成功后根据来源系统 URL 跳转回去。
基于 Cookie 实现的基于 Session 的单点登录有其局限性,那就是不同系统之间主域名需要一致,否则不能生效,所以下一篇我们将介绍更加通用的基于 CAS 的单点登录解决方案。
15 条评论
主系统和子系统 session id 一致,但子系统始终拿不到主系统的用户信息,子系统无法通过主系统的 auth:api 中间件
API 路由里 session 都是无效的 所以取不到
那这里应该怎么处理呢?可否给点提示,子系统需要拿到认证后的用户数据。看你上面教程就是这样写的,不知是怎么取到的,laravel_token 也成功生成并传递了。
子系统使用自己的退出清理了session也可以全部的退出,为什么还要请求登录中心呢?
因为其他子系统也要一起退出