什么是 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_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 单点登录相关路由定义及实现:
基于 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
,出现如下界面,说明配置成功:
至此,我们的 CAS Server 安装配置告一段落,实际上,现在 blog56
项目已经具备了一个 CAS Server 的所有功能,就等着客户端应用接入完成单点登录了。下一篇我们就来搭建 CAS 客户端并演示完整的单点登录实现流程。