什么是 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),从而保证数据传输的安全性。我们将上述单点登录过程通过一张流程图演示如下:

cs架构的单点登录客户端_Server

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

如果单独摆出这个理论,可能你还有点不知所云,下面我们结合具体的实例来演示如何基于 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

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

cs架构的单点登录客户端_单点登录_02

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

  • cas_tickets 用于存放所有生成的 Ticket;
  • cas_services 和 cas_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 单点登录相关路由定义及实现:

cs架构的单点登录客户端_单点登录_03

基于 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;
     }
 }

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

//在文件顶部引入以下命名空间
 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,出现如下界面,说明配置成功:

cs架构的单点登录客户端_Server_04

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