通过 Cookie 实现基于 Session 的单点登录

单点登录及实现思路

单点登录(Single Sign On),简称为 SSO,意思是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的其它应用系统。一般常用于同一家公司的不同子系统之间的登录认证。

单点登录有多种实现方式,这里我们只介绍两种,一种是基于 Cookie 凭证,这种方式适用于子系统之间主域名一致,因为只有这样才能让不同子系统之间共享 Cookie;另一种是通过 CAS 实现 SSO 系统,这种方案适用于所有场景,只是相比较 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

单点登录需要一个独立的登录中心,我们以主系统 blog 作为登录中心,所有子系统登录请求都跳转到这里。

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.phpproviders 配置项修改如下:

'providers' => [
    'users' => [
        'driver' => 'sso',
        'model' => App\User::class,
    ],

    // 'users' => [
    //     'driver' => 'database',
    //     'table' => 'users',
    // ],
],

最后,在 AuthServiceProviderboot 方法中注册自定义的 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']);
    });
}

为了在子系统中获取登录中心用户 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 跳转回去。

上一篇: 通过监听注册登录、邮箱验证事件实现简单的积分功能

下一篇: 基于 CAS 实现通用的单点登录解决方案(一):CAS 原理及服务端搭建