Web 应用采用 browser/server 架构,HTTP 作为通信协议。HTTP 是无状态协议,浏览器的每一次请求,服务器都会独立处理,不会与之前或之后的请求产生关联,这个过程用下图说明,三次请求/响应对之间没有任何联系。

这也意味着,任何用户都能通过浏览器访问服务器资源,如果想保护某些资源,必须限制浏览器请求;也就需要能够鉴别浏览器请求,响应合法请求,忽略或拒绝非法请求。要鉴别浏览器请求,必须清楚浏览器请求状态,既然 HTTP 协议无状态,那就让服务器和浏览器共同维护一个状态吧!这就是会话机制。
浏览器第一次请求服务器,服务器创建一个会话,并将会话的 id 作为响应的一部分发送给浏览器,浏览器存储会话 id,并在后续第二次和第三次请求中带上会话 id,服务器通过请求中的会话id就知道是不是同一个用户了,如下图所示:

浏览器怎么保存会话 id 呢?
使用 Session 技术。浏览器将会话 id 通过 Cookie 保存在本地,并在请求服务器的时候带上此会话 id。

有了会话机制,登录状态就好理解了,我们假设浏览器第一次请求服务器需要输入用户名和密码验证身份,服务器拿到后去数据库比对,正确的话说明当前用户是合法用户,应该将这个会话标记为「已授权」或者「已登录」状态。既然是会话的状态,自然要保存到 Session 中。
PHP 代码如下:
$_SESSION['isLogin'] = true;获取登录状态:
$isLogin = $_SESSION['isLogin'];
每次请求受保护的资源时,都会检查会话的登录状态,只有 isLogin=true 的会话才能访问,登录机制因此实现。
Web 系统已从单系统发展成为如今由多系统组成的应用群,面对如此多的系统,用户难道要一个个登录,然后一个个注销吗?

系统复杂性应该由系统内部承担,而不是用户。无论 Web 系统内部多么复杂,对用户而言,都是一个统一的整体,也就是说,用户访问整个应用群与访问单系统一样,登录/注销只要一次就够了。

单系统登录解决方案的核心是 Cookie,Cookie 携带会话 id 在浏览器和服务器之间维护会话状态。但 Cookie 是有限制的,这个限制就是 Cookie 的作用域 (通常对应网站的域名),浏览器发送 HTTP 请求时会自动携带与该域匹配的 Cookie,而不是所有 Cookie。
既然这样,为什么不将 Web 应用群中所有子系统的域名统一在一个域名下,例如 *.baidu.com,然后将他们的 Cookie 域设置为 baidu.com,这种做法是可以的,甚至早期很多多系统登录就是采用这种同域共享 Cookie 的方式。
然而,可行并不代表好,共享 Cookie 的方式存在众多局限:
首先,应用群域名得统一;
其次,应用群各系统使用 Cookie 的 key 值(PHP 为 PHPSESSID)要相同,不然无法维持会话;
第三,Cookie 本身不安全,有可能被盗取或者伪造。
因此,我们需要一种全新的登录方式来实现多系统应用群的登录,这就是单点登录。
什么是单点登录?
单点登录全称 Single Sign On(以下简称 SSO),是指在应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录。简单来说,一次登录,多处登录;一次注销,多处注销。具体包括单点登录与单点注销两部分。
SSO 需要一个独立的认证中心,只有认证中心能接受用户的用户名密码等信息,其他系统不提供登录入口,只接受认证中心的间接授权。间接授权通过令牌实现,SSO 认证中心验证用户的用户名密码没问题,则创建授权令牌,在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以基于此创建局部会话,局部会话登录方式与单系统的登录方式相同。这个过程,也就是单点登录的原理,用下图说明:

对上图简要描述:
用户访问系统 1 的受保护资源,系统 1 发现用户未登录,跳转到 SSO 认证中心,并将自己的地址作为参数传过去;
SSO 认证中心发现用户未登录,将用户引导至登录界面;
用户输入用户名密码进行登录;
SSO 认证中心校验用户信息,创建用户与 SSO 认证中心之间的会话(全局会话),同时创建授权令牌,并保存该令牌;
SSO 认证中心带着令牌跳转回最初的请求地址(系统 1);
系统 1 拿到令牌,去 SSO 认证中心校验令牌是否有效;
SSO 认证中心校验令牌,返回有效,并在 SSO 认证中心登记本次授权信息;
系统 1 使用该令牌创建与用户的会话(局部会话),返回受保护资源;
用户访问系统 2 的受保护资源;
系统 2 发现用户未登录,跳转到 SSO 认证中心,并将自己的地址作为参数传过去;
SSO 认证中心发现用户已登录(用户与 SSO 认证中心的全局会话在「流程 4」中已建立),跳转回系统 2 的地址,并附带上令牌;
系统 2 拿到令牌,去 SSO 认证中心校验令牌是否有效;
SSO 认证中心校验令牌,返回有效,并在 SSO 认证中心登记本次授权信息;
系统 2 使用该令牌创建与用户的局部会话,返回受保护资源。
用户登录成功后,会与 SSO 认证中心及各个子系统建立会话,用户与 SSO 认证中心建立的会话称为全局会话,用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过 SSO 认证中心,全局会话与局部会话有如下约束关系:
局部会话存在,全局会话一定存在。
全局会话存在,局部会话不一定存在。
全局会话销毁,局部会话必须被销毁。
你可以通过博客园、百度、CSDN、淘宝等网站的登录过程加深对单点登录的理解,注意观察登录过程中的跳转 URL 与参数。
单点登录自然也要单点注销,在一个子系统中注销,所有子系统的会话都将被销毁,用下图来说明:

SSO 认证中心一直监听全局会话的状态,一旦全局会话被销毁,监听器将通知所有子系统执行注销操作。
下面是对上图的简要说明:
用户向系统 1 发起注销请求;
系统 1 根据用户与系统 1 建立的会话 id 拿到令牌,向 SSO 认证中心发起注销请求;
SSO 认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌登录的系统地址;
SSO 认证中心向所有登录系统发起注销请求;
各登录系统接收 SSO 认证中心的注销请求,销毁局部会话;
SSO 认证中心引导用户到登录界面。
单点登录涉及 SSO 认证中心与众子系统,子系统与 SSO 认证中心需要通信以交换令牌、校验令牌及发起注销请求,因而子系统必须继承 SSO 的客户端,SSO 认证中心则是 SSO 服务端,整个单点登录过程实质是 SSO 客户端与服务端通信的过程。
只是简要介绍下基于 PHP 的实现过程,不提供完整源码,明白了原理,可以自己实现。SSO 采用客户端/服务端架构,我们先看 SSO-Client 与 SSO-Server 要实现的功能。
拦截子系统未登录用户请求,跳转到 SSO 认证中心;
接收并存储 SSO 认证系统中心发出的令牌;
与 SSO-Server 通信,校验令牌的有效性;
建立局部会话;
拦截用户注销请求,向 SSO 认证中心发送注销请求;
接收 SSO 认证中心发出的注销请求,销毁局部会话。
验证用户的登录信息;
创建全局会话;
创建授权令牌;
与 SSO-Client 通信发送令牌;
校验 SSO-Client 令牌有效性;
登记已登录系统;
接收 SSO-Client 注销请求,注销所有会话。
接下来,我们按照原理来一步步实现 SSO。
SSO-Client 拦截未登录请求
if (isset($_SESSION['isLogin'])) {
echo 'login success';
} else {
// 未登录,跳转到 SSO 认证中心
header("Location:sso-server-url?redirect=sso-client-url");
exit;
}SSO-Server 拦截未登录请求
拦截从 SSO-Client 跳转到 SSO 认证中心的未登录请求,跳转到登录界面,这个过程与 SSO-Client 完全一样。
SSO-Server 验证用户登录信息
用户在登录页面输入用户名密码,请求登录,SSO 认证中心校验用户信息,校验成功,将会话状态标记为「已登录」。
if ($isValideUser) {
// 登录验证成功
$_SESSION['isLogin'] = true;
}SSO-Server 创建授权令牌
授权令牌是一串随机字符,以什么样的方式生成都没有关系,只要不重复、不易伪造即可。
function grantToken($length = 32)
{
$str = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$token = '';
for ($i = 0; $i < $length; $i++) {
$token .= $str[mt_rand(0, strlen($str) - 1)];
}
return $token;
}SSO-Client 取得令牌并校验
SSO 认证中心登录后,跳转回子系统并带上令牌,子系统取得令牌,然后去 SSO 认证中心去校验。
$token = isset($_GET['token']) ? $_GET['token'] : '';
if (! $token) {
$verifyToken = file_get_contents('sso-server-url?token=' . $token . '&redirect=' . 'sso-client-url');
if ($verifyToken == 'success') {
//有效的token
} else {
header('Location:sso-server-url');
exit;
}
}SSO-Server 接收并处理校验令牌请求
用户在 SSO 认证中心登录成功后,SSO-Server 创建授权令牌并存储该令牌,所以,SSO-Server 对令牌的校验就是去查找这个令牌是否存在以及是否过期,令牌校验成功之后,SSO-Server 将发送校验请求的系统登记到 SSO 认证中心。
令牌与登录系统地址通常存储在 key-value 数据库(如 Redis 的集合)中,Redis 可以为 key 设置有效时间,也就是令牌的有效期。Redis 运行在内存中,速度非常快,正好 SSO-Server 不需要持久化任何数据。
可能你会问,为什么要存储这些系统的地址?如果不存储,注销的时候就麻烦了,用户向 SSO 认证中心提交注销请求,SSO 认证中心注销全局会话,但不知道哪些系统用此全局会话建立了自己的局部会话,也不知道要向哪些子系统发送注销请求注销局部会话。
SSO-Client 校验令牌成功创建局部会话
令牌校验成功后,SSO-Client 将当前局部会话标记为「已登录」。
if ($verifyToken == 'success') {
$_SESSION['$isLogin'] = true;
}注销过程
用户向子系统发送带有「logout」参数的请求(注销请求),SSO-Client 拦截器拦截该请求,向 SSO 认证中心发起注销请求。
if ($action == 'logout') {
$token = $_SESSION['token'];
header("Location:sso-server-url?token=" . $token);
exit;
}SSO 认证中心也用同样的方式识别出 SSO-Client 的请求是注销请求(带有 logout 参数),SSO 认证中心注销全局会话。
SSO 认证中心有一个全局会话的监视器,一旦全局会话注销,将通知所有登录系统注销。
if ($action == 'logout') {
foreach($sso_client_url_array as $sso_client_url){
echo "<script src='$sso_client_url?token=$token'></script>"
};
echo 'logout success';
}整个单点登录的难点在于能否理清整个流程,以及区分会话双方,避免 Session 混用。
对于单点登录,不止这一种实现方式,但是原理基本类似;
如何确保 token 验证的安全:
每个子系统拥有一对 app_id 和 app_secret,校验 token 的时候,带上 app_id、token 以及和 app_secret 的签名,确保数据不被修改;
如何较好的集成到现有系统,降低耦合度;
理解其他授权认证方式,比如 OAuth 认证:
OAuth,是先在第三方应用请求用户授权,用户在认证服务器登录认证之后,第三方应用拿着 Access 去认证服务器获取用户信息,但是并没有共享 Session。也就是说下次在另一个应用登录时,仍然需要去认证服务器登录认证。而单点登录是共享 Session 的,下次访问其他应用时,会共享本次的登录状态,不需要再次登录。