本文总阅读量:  次 | 文章总字数: 6,389 字

跨域SSO的实现之一:架构设计

翻译自 CodeProject 网站 ASP.NET 9 月份最佳文章:Single Sign On (SSO) for cross-domain ASP.NET applications
翻译不妥之处还望大家多多指导、相互交流。
文章分为两部分:架构设计和程序实现,此为第一篇即:架构设计或者叫设计蓝图(Part-I - The design blue print)。

简介

周一的早晨,当你正在纳闷周末咋就一眨眼过去了并对接下来漫长的一周感到无比蛋疼之时,你收到了一份 Email。

操蛋的是它既不是微软的 offer 也不是 Google 的 offer,而是客户发来的一个新需求。

他说你们现在帮我们公司做了很多的 ASP.NET 的网站和忽悠我们上线的各种系统,现在我想要我的客户只要在我们拥有的任何一个网站上登录一次,那么在我所有的网站上该用户就都已经登录了,同样,随便他从哪个网站上注销掉,那么他也就从我们所有的网站上注销了……

你受不了客户这么罗嗦了,心想不就是要一个 SSO 功能吗?使用 ASP.NET 的 form authentication 不就可以实现了?因为这样可以在同域的不用网站下共享 cookie,只需要在 machineKey 设置一样的配置节就可以了。放狗一搜,果然有 xxxx 条结果。放狗找东西可是我们程序员的特长。

开工前,你又扫了一眼邮件,等等,你看到了邮件中的一行话,微微一蛋疼:我们部署了那些网站,但不是都在同一个域名下。

你的客户狠狠地给你来了个下马威,好像他早就放狗搜过,因为 cookie 不能跨域共享,也就不能用来实现跨域验证了。

这到底是神马一回事情!(和老外一样扯玩淡,下面正经些)

ASP.NET 中的验证原理

这个问题可能是老生常谈了,但在解决难题之前,还是先回归基础来看一看事物的本质到底是如何的。因此,我们重温一下 ASP.NET 表单验证的原理也并不坏。
下面是 ASP.NET 表单验证的流程图

验证流程

1:你访问一个需要用户验证的 ASP.NET 页面

2:在此请求中 ASP.NET 运行时开始查找 cookie(由于表单验证的 cookie),如果没有查找到,那么将跳转到登录页面(登录页地址配置在了 web.config 文件中)

3:在登录页面中,你提供了相关的验证凭证并点击了登录按钮,系统和已存储的数据对比验证成功后,将 Thread.CurrentPrincipal.Identity.Name 的属性值设置成了你提供的用户名,并在 Response 中写入了 cookie(同时还写入了用户信息和一些如 cookie 名,失效日期等),并重定向到登录前的页面。

4:当你再点击其他的页面(或者点击导航到其他的页面),浏览器发送验证的 cookie(也可能包含在该网站下写入的一些其他 cookie),这一次已经包含了在上一次 response 中上次验证获取到的 cookie。

5:和以前一样,ASP.NET 运行时在请求中查找验证的 cookie,这一次找到了,接下来做一些检查(如失效日期、路径等等),如果还没有失效,那么读取出它的值,恢复出用户的信息,将 Thread.CurrentPrincipal.Identity.Name 的属性值设置成恢复出的用户名,检查该用户是否有权限去访问当前请求的页面,如果有,那么页面执行的结果返回到用户的浏览器。

过程很简单,对吗?

ASP.NET 中多站点同域下的验证原理

如前所述,ASP.NET 表单验证完全依赖于 cookie。那么只要使得不同的站点共享同样的验证 cookie,那么就可以实现在一个站点登录实现所有站点的登录。

HTTP 协议指出,如果两个站点是同域(或者是子域)的,那么可以共享 cookie。本地的处理是浏览器根据网站的 URL 存储 cookie 在本地(磁盘或者内存中)。当你请求接下来的任意页面时,浏览器读取和当前请求的 URL 匹配的域或子域的 cookies,并将此 cookies 包含在当前的请求中。

现在我们假设有下面两个网站:

www.mydomain.com/site1

www.mydomain.com/site2

这两个站点共享同样的主机地址(同样的域 mydomain.com 和子域 www),且两个站点都被配置成了对用户验证和授权都使用表单验证。假设你已经登录过了站点www.mydomain.com/site1,如前所述,你的浏览器现在对于站点www.mydomain.com/site1已经有了表单验证的 cookie。

现在你随意访问以www.mydomain.com/site1开头的 URL,表单验证的 cookie 都将被包含在请求被发送。为什么?是因为此 cookie 本来就属于该站点吗?对的,但不是完全正确。事实上,是因为请求的 URL:www.mydomain.com/site1https://www.mydomain.com/拥有同样的域名和子域名。

那么在你登录了www.mydomain.com/site1后,如果你点击www.mydomain.com/site2下的 URL,表单验证的 cookie 也将被包含在请求中发送,这同样是因为www.mydomain.com/site2与站点https://www.mydomain.com/拥有同样的域名和子域名,尽管它是不一样的应用站点(site2)。显然,在拥有一样主机地址不一样的应用站点名之间是可以共享表单验证 cookie 的,这样就实现了一处登录处处都已经登录的功能(也就是单点登录)。

然而,ASP.NET 没有允许你仅仅通过将同主机地址下的站点部署上表单验证后就自动完成了单点登录。为什么这样呢?因为每一个不同的 ASP.NET web 应用程序使用它自己的密钥去加密和加密 cookie(还有诸如 ViewState 之类的)从而确保了安全。除非你给每一个站点指定了同样的加密密钥,那么 cookies 将被发送,但是另一个应用站点不能够读取验证 cookies 的值。

指定同样的验证密钥可以解决这个问题。为每一个 ASP.NET 应用站点使用同样的配置节即可,如下:

<machineKey   
  validationKey="21F090935F6E49C2C797F69BBAAD8402ABD2EE0B667A8B44EA7DD4374267A75D"   
  decryptionKey="ABAA84D7EC4BB56D75D217CECFFB9628809BDB8BF91CFCD64568A145BE59719F"  
  validation="SHA1"  
  decryption="AES"/>

如果同样的 machinekey(包括 validationKey 和 decryptionKey)被用在同域下的所有应用站点时,就可以实现了跨站点读取 cookie。

如果是同样的域不同的子域呢?

假定你有下面两个站点:
site1.mydomain.com
site2.mydomain.com
这两个站点共享同样的域(同样的二级域名 mydomain.com),但拥有不一样的三级域名(不一样的子域 site1 和 site2)。
默认情况下浏览器仅仅发送主机地址一样(相同的域和子域)的站点的 cookie。因此站点 site1.mydomain.com 不能获取到站点 site2.mydomain.com 下的 cookie(因为他们没有相同的主机地址,它们的子域不同),尽管你为这两个站点配置了相同的 machineKey,一个站点还是不能获取另一个站点下的 cookie。
除了你为所有的站点配置了一样的 machineKey,你还需要为验证 cookie 定义相同的域以使得浏览器在同样的域名下能够发送任何请求。

你需要像下面这样配置表单验证 cookie:

<forms name=”name” loginUrl=”URL” defaultUrl=”URL” domain=”mydomain.com”/>

显然这是不可能的,因为 HTTP 协议基于安全的原因阻止了你在不同的域之间共享 cookie。
同样,假设有下面这两个域名:
https://www.domain1.com/
https://www.domain2.com/
如果你使用表单验证登录进了https://www.domain1.com/,当你点击https://www.domain2.com/下的 URL 时,浏览器将不能发送 domain1.com 的 cookie 到 domain2.com。在 ASP.NET 中没有内置的方法去完成在两个不同的站点间实现单点登录。
要在两个站点间通过访问同样的 cookie 来实现单点登录,还真没有什么高级的技巧或即有的架构模型去解决它。

跨域单点登录设计雏形

假设有下面三个站点:
https://www.domain1.com/

https://www.domain2.com/
https://www.domain3.com/
为了实现在这些站之间实现 SSO,当用户在任意一个站登录时,我们需要为所有的站点设置验证 cookie。
如果用户 1 登录进https://www.domain1.com/,那么在给站点 1response 前会在 response 中加入验证的 cookie,但当我们需要同时能够登录进https://www.domain2.com/https://www.domain3.com/时,我们需要同时在同样的客户端浏览器上为站点 2 和站点 3 设置验证 cookie。因此,在 response 返回到浏览器前,站点 1 不得不定向到站点 2 和站点 3 去设置验证 cookie。

下面的流程图详细描述了思路:

操作流程:

请求https://www.domain1.com/中一个需要验证的页面
状态:浏览器没有验证 cookie浏览器发送一个请求到https://www.domain1.com/,但请求中没有验证 cookie(因为还没有属于https://www.domain1.com/的 cookie)。
状态:浏览器没有验证 cookie
因为请求中没有验证 cookie,所以请求https://www.domain1.com/的登录页面
状态:浏览器没有验证 cookie用户提供登录凭证点击登录按钮,浏览器发送一个 POST 请求到https://www.domain1.com/
https://www.domain1.com/验证用户提供的登录凭证,验证通过后,标记用户的状态为已登录,添加验证的 cookie 和其他的用户信息一起添加在 response 中
状态:浏览器没有验证 cookieresponse 并没有返回给浏览器,而是将请求重定向到https://www.domain2.com/的一个页面,并将 ReturnUrl 设置成重定向前https://www.domain1.com/的 URL 值。在验证 cookie 被包含在了 response 中了后,cookie 被发送给浏览器。
状态:浏览器没有验证 cookie浏览器接收到了包含验证 cookie 的 response 和重定向到https://www.domain2.com/的命令。浏览器存储了https://www.domain2.com/的验证 cookie 并向https://www.domain2.com/发送请求。
状态:浏览器包含了 https://www.domain2.com/的验证 cookiehttps://www.domain2.com/
立即再重定向到存储在 ReturnUrl 中的 URL 地址,在此请求中读取 cookie 值并为https://www.domain1.com/设置验证 cookie。最终,在重定向的命令中也包含了这些验证 cookie。
状态:浏览器包含了https://www.domain2.com/的验证 cookie浏览器接收到包含了验证 cookie 的重定向命令跳转到https://www.domain1.com/。现在浏览器存储了站点 1 的验证 cookie 并开始请求站点 1,当然在请求中包含了验证 cookie。
状态:浏览器包含了https://www.domain1.com/和[https://www.domain2.com/](https://www.domain2.com/)的验证cookie站点 1 检查了请求中包含了验证 cookie,就不需要再去跳转到验证页面去验证,而是返回用户请求的页面
状态:浏览器包含了https://www.domain1.com/和https://www.domain2.com/的验证cookie

如果此时用户请求站点 2, 因为浏览器已经存储了站点 2 的验证 cookie,cookie 将被包含在请求中,站点 2 从 cookie 中获取到用户信息,并为此用户返回请求的页面。
当浏览器验证了站点 2 和站点 3 后,那么用户就已经登录了所有的站点,这样就完成了一次单点登录。

如何单点注销?

作为单点登录的一部分,我们还需要去关注下单点注销,就是说当用户在一个站点注销后,那么就认为他从所有的站点都注销了。
清除所有站点的 cookie 和上面登录一样,也是请求-重定向-返回的过程。只是和设置验证 cookie 不一样的是,这次从 response 中移除验证 cookie。

此单点登录模型的缺点

这个模型在两个站点上还是能运行的很好的。从一个站点登录或注销,此 SSO 模型下的站点都将遵从请求-重定向-返回的流程。当用户登录任一页面时,因为已经存储了所有站点的验证 cookie,那么就不需要再执行上面的那个循环的流程了。
但是当站点超过两个时,问题就变得复杂了,当登录站点 1 时,程序将重定向到站点 2 和站点 3 进行验证 cookie 的设置,最后站点 3 在跳转到站点 1,服务器返回用户请求的页面。这使得每个站点的登录和注销的过程变得复杂并花费较高的代价。如何超过 3 个站点呢?如果这样去设计 20+站点的单点登录呢?这个模型将完全不能胜任了。
并且此模型需要每个站点都具备用户验证逻辑,因为需要来请求此站点并设置其验证 cookie。
因此此模型丢失了一般意义上的单点登录的概念,我们需要一个更好一点的模型去实现单点登录的功能。

更好的跨域单点登录架构

前面提到的架构中,设置移除 cookie 都需要跳转到 N-1 个站点去完成。每个站点还需要知道 N-1 个站点复杂的登录注销逻辑,
如果我们为所有的站点只去维护一份验证 cookie 呢?使用一个独立的站点去完成验证用户并设置验证 cookie 的工作呢?这个想法好像不错。
要使用单点登录,那么就需要用户的数据是统一的,这样的话就可以通过一个站点提供 web 或者 WCF 服务来完成验证和授权的功能。这样就省去了冗余的用户验证逻辑,现在最重要的是这个独立的站点如何在 SSO 架构中起作用。
在这个架构模型中,浏览器不存储任何其他站点的验证 cookie,只存那个独立站点的验证 cookie,我们就给它起名叫https://www.sso.com/
在此架构中,对每一个站点的请求都将被直接跳转到https://www.sso.com/,由于检查验证 cookie 是否存在。如果 cookie 存在,如果存在,返回请求的页面,如果不存在,那么就跳转到对应的登录页面。
大致流程图如下:

便于理解,我们假定有下面两个网站:
https://www.domain1.com/
https://www.domain2.com/
还有一个用于管理验证 cookie 的站点:https://www.sso.com/
验证流程如下:

用户请求https://www.domain1.com/中一个需要验证的页面
重定向到https://www.sso.com/,ReturnUrl 参数设置成请求站点 1 时的 URL。
https://www.sso.com/检查是否有验证 cookie 存在,如果在请求中没有任何用户令牌存在,那么请求中带着用户需要登录的指令就跳转到站点 1。在 query string 中仍然保留着之前 ReturnUrl 参数的值。
站点 1 从参数中得知是从https://www.sso.com/跳转而来,且得知没有用户验证 cookie,最后跳转到站点 1 的登录页面进行登录,而不跳转到https://www.sso.com/
用户提供验证信息点击登录按钮,请求没有回置到站点https://www.sso.com/,这时,站点 1 通过https://www.sso.com/提供的 web/WCF 接口进行用户的验证,如果验证成功,那么为用户颁发一个令牌(可以是一个 GUID)。
站点 1 标志用户已经登录成功(在 session 中存储用户对象),一个包含了令牌的 URL 跳转到https://www.sso.com/设置验证 cookie,ReturnUrl 参数还是设置成前面请求的 URL。
https://www.sso.com/站点检查过来的 URL,发现有用户令牌,但还没有用户验证 cookie,说明已经通过了站点 1 的认证,现在需要设置站点https://www.sso.com/下的验证 cookie。照例设置好了 cookie 后,将 cookie 添加在 response 中,还添加上用户令牌按照 ReturnUrl 参数中的 URL 一并返回。
浏览器得知要跳转到站点 1,并且有了站点https://www.sso.com/的验证 cookie,在本地存储下 sso 站点的验证 cookie 并对站点 1 发起请求。
站点 1 检查了用户令牌,因为是通过站点 SSO 的 web/WCF 服务验证并通过的,所以站点 1 返回用户请求的页面。

现在用户请求站点 2
浏览器跳转到 sso 站点,依然设置好 ReturnUrl 的值。
浏览器因为要跳转到 sso 站点,发现本地有了 sso 站点的验证 cookie,所以将 cookie 添加在请求中一并发出。
sso 站点检查 cookie,发现 cookie 还没有过期,那么在 query string 中添加上用户令牌按照 ReturnUrl 返回。
站点 2 发现有用户令牌,证明已经走过验证流程,那么就返回用户请求的页面。

总结

刚开始,浏览器没有任何https://www.sso.com/站点下的验证 cookie。请求站点 1 和站点 2 任何需要验证的页面(需要内部的跳转到 sso 站点检查验证 cookie 是否存在)。用户登录后,sso 站点的验证 cookie 存储在本地(重要的是用户令牌仅仅用户用户登录会话时)。
现在请求站点 1 或者站点 2 都跳转到 sso 站点,浏览器发送 sso 站点的验证 cookie 并检查用户令牌,验证后再跳转到原始请求的 URL,原始站点检查用户令牌正确后返回用户请求的页面。

传输代价

场景 1:访问公共页面 从浏览器到站点+站点到浏览器
1 请求+1 返回
场景 2:访问一个需要验证的页 从浏览器到站点+重定向到 sso 站点(检查 cookie)+重定向到原站点(没有 cookie)+原站点返回登录页面到浏览器
1 请求+2 跳转+1 返回
场景 3:登录 浏览器 POST 到站点+调用验证服务进行用户验证+浏览器跳转到 SSO 站点(带有令牌)+重定向到原站点(带有验证 cookie)+通用服务验证令牌+返回用户请求的需要验证的页面
1 请求+2 验证服务调用+2 跳转+1 返回
场景 4:登录后请求一个需要验证的页面 请求站点+向 SSO 站点跳转验证 cookie(带有验证 cookie)+跳转到原站点(检查验证 cookie)+调用服务验证令牌+返回请求页面
1 请求+2 跳转+1 服务请求+1 返回
场景 5:注销 请求站点进行注销+请求 SSO 站点进行注销+请求原站点移除验证 cookie+返回
1 请求+2 跳转+1 返回

孰是孰非

比较这两种架构,第一中架构更适合两个站点,最多三个站点,虽然需要部署复杂冗余的验证逻辑,但是随后的页面请求中就是普通的页面请求了(1 请求+1 返回)。
第一种架构不易于扩展,且会冗余出很多的用户验证逻辑。
而第二种架构,不管有多少个需要进行单点登录的网站,也不需要其他网站参与此过程,验证的 cookie 只有 sso 站点管理,这样的架构逻辑清晰、易扩且部署方便。
然而有一些性能的问题,不同于第一种架构,这种架构当用户请求一个需要验证的页面时需要请求三次(请求 sso 站点和原站点,两次请求是内部的跳转),且多的两次请求花费的时间很少(空跳转请求,用于设置和检查 cookie),在如今这样的网络环境下是可以接受的。

第二种架构的程序实现

等有空了来翻译完第二部分:程序实现
等不及的朋友可以先看原文:https://www.codeproject.com/KB/aspnet/CrossDomainSSOExample.aspx

程序实现源码下载

EOF

转载须以超链接形式标明文章原始出处和作者信息