通过 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 Comments
主系统和子系统 session id 一致,但子系统始终拿不到主系统的用户信息,子系统无法通过主系统的 auth:api 中间件
API 路由里 session 都是无效的 所以取不到
那这里应该怎么处理呢?可否给点提示,子系统需要拿到认证后的用户数据。看你上面教程就是这样写的,不知是怎么取到的,laravel_token 也成功生成并传递了。
子系统使用自己的退出清理了session也可以全部的退出,为什么还要请求登录中心呢?
因为其他子系统也要一起退出