通过 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.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']);
    });
}

子系统 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 的单点登录解决方案。


点赞 取消点赞 收藏 取消收藏

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

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