[ Laravel 从入门到精通 ] 用户认证与授权系列 —— 基于 CAS 实现通用的单点登录解决方案(一):CAS 原理及服务端搭建

什么是 CAS

上篇教程我们介绍了单点登录,以及如何基于 Cookie 实现简单的单点登录,这篇教程,我们将基于 CAS 实现更加通用的单点登录解决方案,不再受域名约束。

CAS(Central Authentication Service)是耶鲁大学的一个开源项目,旨在为 Web 应用系统提供一种可靠的单点登录解决方案。采用 CAS 最大的因素是从安全性角度来考虑,用户在 CAS 服务端录入用户名和密码之后通过 Ticket 在不同系统间进行认证,不会在网上传输密码,从而保证安全性。

CAS 具有以下特点:

  • 开源通用的企业级单点登录解决方案;
  • 一个 CAS Server,多个 CAS Client(需要认证的 Web 应用)。
  • CAS Client 支持非常多的客户端,包括 Java、.Net、PHP、Ruby 等。

CAS 单点登录原理

典型的 CAS 单点登录实现方案涉及至少三个方面:CAS Server、CAS Client(需要认证的 Web 应用)、客户端浏览器。

我们先来看 CAS 单点登录的实现流程。

首先,用户在客户端浏览器访问 Web 应用(姑且称之为应用 A),发起登录请求,Web 应用 A 会将认证请求重定向到 CAS Server,同时在客户端写入一个 Cookie,CAS Server 会验证用户是否已经认证,如果没有认证会进行认证操作(一般通过数据库进行匹配),同时生成一个 Ticket(保存起来留待后续验证时用);然后 CAS Server 会通过带有 Ticket 的回调地址重定向回 Web 应用 A,此时 Web 应用 A 还不知道用户是否已经认证,会再次通过这个 Ticket 去 CAS Server 进行校验,如果校验通过,则从服务端删除该 Ticket,并返回认证用户信息给 Web 应用 A,Web 应用 A 根据用户信息及一开始写入的 Cookie 设置 Session,至此,用户在 Web 应用 A 中完成登录认证。

注:需要注意的是这个 Ticket 是一次性的,是与指定用户与服务相关联的,用过一次就废弃了。

如果还有另一个 Web 应用(将其称之为应用 B)此时也发起了登录请求,同样会将认证请求重定向到 CAS Server,同时在客户端写入一个 Cookie,此时用户在 CAS Server 已经处于登录状态(如果退出还需要重新登录),但是是不同应用发起的认证服务,所以,会重新生成一个 Tikcet,然后重定向回这个 Web 应用 B,同上面应用 A 的认证逻辑一样,这个 Web 应用 B 也会通过这个 Ticket 去 CAS Server 验证认证状态,验证成功后废弃该 Ticket,将用户信息返回给 Web 应用 B,应用 B 基于用户信息和一开始写入的 Cookie 设置 Session,这样 Web 应用 B 也完成单点登录。

由此可见,其实真正的登录操作是在 CAS Server(登录中心)实现的,客户端 Web 应用 A、B 都是通过 Ticket 进行登录状态验证,验证通过后各自设置 Session 完成各自系统的认证,从而实现单点登录,这个单点就是 CAS Server。

用户退出的时候,比如从 Web 应用 A 发起退出请求,会在 A 系统先退出,然后将告知 CAS Server 用户退出,这样,CAS Server 会在服务端(登录中心)将该用户退出,并且将退出消息告知其它子系统,其它子系统再各自完成退出操作,从而完成了所有系统的用户退出操作。

整个过程中,子系统与 CAS 服务端之间的交互均采用 SSL 协议(HTTPS),从而保证数据传输的安全性。我们将上述单点登录过程通过一张流程图演示如下:

整个过程应该就非常清晰了。

如果单独摆出这个理论,可能你还有点不知所云,下面我们结合具体的实例来演示如何基于 Laravel 框架实现基于 CAS 的单点登录。

服务端配置

我们首先来搭建 CAS Server。

安装配置

常见的 CAS Server 都是基于 Java + Tomcat 来实现的,由于 Laravel 框架基于 PHP 语言开发,所以,我们需要通过 PHP 来实现 CAS Server,好在已经有人为我们探过路了,有一个现成的 Laravel 扩展包 leo108/laravel_cas_server,可用于在 Laravel 框架中实现 CAS 服务端的认证。

但是这个扩展包目前不支持最新的 Laravel 5.7,所以需要通过一个 Laravel 5.5 或 Laravel 5.6 版本的项目进行测试:

composer create-project --prefer-dist laravel/laravel blog56 5.6.*

安装完成后,在 .env 中配置数据库信息,以便后续实现基于数据库的用户认证。然后安装 leo108/laravel_cas_server 扩展包:

composer require leo108/laravel_cas_server

接下来,将 CAS 扩展包配置文件 cas.php 发布到 config 目录下:

php artisan vendor:publish --provider="Leo108\CAS\CASServerServiceProvider"

该配置文件中包含 CAS 服务端的通用配置,可以去看一眼了解下。

接下来,就可以运行数据库迁移命令创建相关的数据表了:

php artisan migrate

该命令执行完成后,会在数据库中生成如下数据表:

其中以 cas_ 开头的都是与 CAS 认证相关的数据表:

  • cas_tickets 用于存放所有生成的 Ticket;
  • cas_servicescas_service_hosts 用于存放所有需要认证的 Web 应用及对应服务,需要提前注册才能进行单点登录
  • cas_proxy_granting_tickets 用于存放 CAS 代理相关配置,本示例不会用到该数据表

最后,我们将本项目的域名配置为 blog.test,并且开启 HTTPS。

实现 CAS 用户模型类

修改 User 模型类,让其实现 Leo108\CAS\Contracts\Models\UserModel 接口,然后实现该接口中的方法如下:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Leo108\CAS\Contracts\Models\UserModel;

class User extends Authenticatable implements UserModel
{
    ...

    /**
     * 获取用户名 
     * 需要保证用户名在 CAS 系统中的唯一性,
     * 我们在 CAS Client 通过它的值来唯一确定用户
     *
     * @return string
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * 获取用户属性
     *
     * @return array
     */
    public function getCASAttributes()
    {
        return $this->attributesToArray();
    }

    /**
     * 获取用户模型实例
     * 
     * @return Model
     */
    public function getEloquentModel()
    {
        return $this;
    }
}

编写 CAS 用户认证服务类

CAS 扩展包提供了 CAS 单点登录相关路由定义及实现:

基于 CAS 登录操作对应逻辑位于 \Leo108\CAS\Http\Controllers\SecurityController,其中用户认证相关逻辑通过 Leo108\CAS\Contracts\Interactions\UserLogin 接口进行了依赖注入,因此我们需要编写该接口的实现。

创建一个 App\Services\CAS\UserLogin 类来实现 Leo108\CAS\Contracts\Interactions\UserLogin,并编写该类代码如下:

<?php
namespace App\Services\CAS;

use Illuminate\Foundation\Auth\ThrottlesLogins;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Leo108\CAS\Contracts\Interactions\UserLogin as UserLoginContract;

class UserLogin implements UserLoginContract
{
    use ThrottlesLogins;

    public function login(Request $request)
    {
        $this->validateLogin($request);

        if ($this->attemptLogin($request)) {
            $request->session()->regenerate();
            $this->clearLoginAttempts($request);
            return $this->guard()->user();
        }

        // If the login attempt was unsuccessful we will increment the number of attempts
        // to login and redirect the user back to the login form. Of course, when this
        // user surpasses their maximum number of attempts they will get locked out.
        $this->incrementLoginAttempts($request);

        return null;
    }

    public function getCurrentUser(Request $request)
    {
        return $request->user();
    }

    public function showAuthenticateFailed(Request $request)
    {
        // TODO: Implement showAuthenticateFailed() method.
    }

    public function showLoginWarnPage(Request $request, $jumpUrl, $service)
    {
        // TODO: Implement showLoginWarnPage() method.
    }

    public function showLoginPage(Request $request, array $errors = [])
    {
        $post_login_url = route('cas.login.post');
        if ($request->getQueryString()) {
            $post_login_url .= '?' . $request->getQueryString();
        }
        return view('auth.login', ['post_login_url' => $post_login_url]);
    }

    public function redirectToHome(array $errors = [])
    {
        return redirect('/home');
    }

    public function logout(Request $request)
    {
        $this->guard()->logout();

        $request->session()->invalidate();

        return redirect('/');
    }

    public function showLoggedOut(Request $request)
    {
        // TODO: Implement showLoggedOut() method.
    }

    /**
     * Validate the user login request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return void
     */
    protected function validateLogin(Request $request)
    {
        Validator::make($request->all(), [
            $this->username() => 'required|string',
            'password' => 'required|string',
        ])->validate();
    }

    /**
     * Attempt to log the user into the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function attemptLogin(Request $request)
    {
        return $this->guard()->attempt(
            $this->credentials($request), $request->filled('remember')
        );
    }

    /**
     * Get the needed authorization credentials from the request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    protected function credentials(Request $request)
    {
        return $request->only($this->username(), 'password');
    }

    /**
     * Get the login username to be used by the controller.
     *
     * @return string
     */
    public function username()
    {
        return 'email';
    }

    /**
     * Get the failed login response instance.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Symfony\Component\HttpFoundation\Response
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    protected function sendFailedLoginResponse(Request $request)
    {
        throw ValidationException::withMessages([
            $this->username() => [trans('auth.failed')],
        ]);
    }

    /**
     * Get the guard to be used during authentication.
     *
     * @return \Illuminate\Contracts\Auth\StatefulGuard
     */
    protected function guard()
    {
        return Auth::guard();
    }
}

然后,创建 App\Services\CAS\TicketLocker 类实现 Leo108\CAS\Contracts\TicketLocker 接口,该接口也在 SecurityController 控制器中进行了依赖注入,用于获取锁和释放锁,避免并发问题(测试项目不存在并发问题,直接简单返回 true):

<?php
namespace App\Services\CAS;

use Leo108\CAS\Contracts\TicketLocker as TicketLockerContract;

class TicketLocker implements TicketLockerContract
{
    public function acquireLock($key, $timeout)
    {
        return true;
    }

    public function releaseLock($key)
    {
        return true;
    }
}

接下来,需要在 AppServiceProviderregister 方法中注册上述接口与实现的服务容器绑定:

//在文件顶部引入以下命名空间
use App\Services\CAS\TicketLocker;
use App\Services\CAS\UserLogin;
use Leo108\CAS\Contracts\Interactions\UserLogin as UserLoginContract;
use Leo108\CAS\Contracts\TicketLocker as TicketLockerContract;

public function register()
{
    $this->app->singleton(UserLoginContract::class, function ($app) {
        return new UserLogin();
    });
    $this->app->singleton(TicketLockerContract::class, function ($app) {
        return new TicketLocker();
    });
}

我们在 CAS 服务端还是借助 Laravel 自带的认证视图进行登录认证,运行 php artisan make:auth 生成认证脚手架视图。然后在浏览器中访问 https://blog56.test/cas/login,出现如下界面,说明配置成功:

至此,我们的 CAS Server 安装配置告一段落,实际上,现在 blog56 项目已经具备了一个 CAS Server 的所有功能,就等着客户端应用接入完成单点登录了。下一篇我们就来搭建 CAS 客户端并演示完整的单点登录实现流程。

学院君 has written 1269 articles

Laravel学院院长,终身学习者

积分:177207 等级:P12 职业:手艺人 城市:杭州

9 条回复

  1. 学员君知道怎么走http吗?,https局限性太大,找了半天配置,将ssl的配置改成false并不生效

  2. Joyce Zhang Joyce Zhang says:

    记录下: 在 UserLogin 中 public function login(Request $request); 不要 return null,要 return $this->sendFailedLoginResponse($request); 不然在登录的时候,用户名和密码错误的时候,不会提示错误信息。而是,直接显示一个空白页面。

  3. koko koko says:

    @学院君 你好,请问前后端分离的项目可以使用cas单点登录么?

登录后才能进行评论,立即登录?