基于 CMS 数字签名的 Ticket-based SSO

来源:互联网 发布:淘宝考试答案2017 编辑:程序博客网 时间:2024/06/11 16:16

本文将介绍在 Web 环境下,客户端和服务器端如何基于 CMS(Crypto Message Syntax) 数字签名实现两者之间的 Web SSO(Single Sign On),重点介绍如何用 Java 语言实现 CMS 数字签名的生成与验证,以及如何在此基础上建立一套完整的 SSO 实现(包括服务器端和客户端)。


数字签名和 CMS 简介

数字签名简介

在日常生活中,签名通常被做为个人身份的凭证。当一份文件上有某个人的签名时,便相信此份文件确实由此人审阅过了。与之类似,在数字安全领域中,数字签名也起着类似的作用。

首先,数字签名证实了一份数字信息确实来自于某个实体。因为基于非对称加密的原理,用私钥加密的消息只能用对应的公钥解密,反之亦然。如图 1 所示,签名是由该实体的私钥生成,而私钥只由签名方持有。因此在图 2 中,只能用签名方的公钥对签名进行解密。而当解密成功时,便可相信是签名方生成了此消息。

其次,数字签名可以确保消息在传递过程中未被篡改。如图 1 所示,数字签名是由特定算法的哈希值加密而来。使用 MD5、SHA 等哈希算法可以确保消息哈希值的唯一性。因此在图 2 中,可以通过用同样的算法重新计算消息的哈希值,与签名中的哈希值对比。若结果一致,则可信任该消息在发出后未被篡改。

图 1. 数字签名的生成
图 1. 数字签名的生成
图 2. 数字签名的验证
图 2. 数字签名的验证

CMS 简介

CMS(Crypto Message Syntax)是由互联网工程任务组(IETF)制定的安全消息规范 (RFC 5652)。该规范定义多种消息格式,分别用于对任意消息进行数字签名、哈希、认证和加密。

该规范所规定的数字签名格式可以包含如下内容:哈希值生成算法、签名方的数字证书(包含签名方的公钥)、签名方的基本信息、消息原文、签名等。对比上一节数字签名的生成和验证过程可以发现,CMS 规范所规定格式已经包含了数字签名所有必需的信息。

同时,对于开发者而言,可以利用开源工具方便的生成 CMS 格式的数字签名。例如 Linux 下的 openssl 命令以及 Java 的开源类库 BouncyCastle,都提供了针对 CMS 格式数字签名的生成和验证功能。



单点登录及其常见实现对比

单点登录简介

单点登录,又称 SSO(Single Sign On),指的是对于多个互相信任的系统,用户只要执行一次登录操作,即可访问所有的系统,直到该次登录失效为止。在单点登录环境下,各应用将认证功能交由单点登录系统完成,而不再使用自身的认证系统。使用单点登录前后的用户认证流程如图 3 和图 4 所示。

图 3. 不使用单点登录的多系统认证
图 3. 不使用单点登录的多系统认证

当前单点登录的实现方式虽然各不相同,但绝大部分都是 Ticket-based SSO。这类实现方式的特点是,在用户首次登录后系统将生成一条字符串,称为 ticket(又称 token),作为后续用户认证的凭据。用户访问被保护的应用时,在请求中携带 ticket。如果经验证 ticket 有效,则允许用户访问。

通常对于 ticket 会设定一个失效时间,过了该时间点后该 ticket 将不再被认为有效。极端情况下,一条 ticket 只能被使用一次。这种方式在一定程度可以避免因为 ticket 被第三方截获而导致系统安全受到威胁的情况。但要彻底杜绝这种可能性,通常需要采用 HTTPS 以确保信息传输的安全。

图 4. 使用单点登录的多系统认证
图 4. 使用单点登录的多系统认证

单点登录常见实现对比

以下将通过例举单点登录系统的几个重要特征,来对比介绍 CAS、Kerberos、OAuth、Openstack Keystone 这几种常见的单点登录实现(协议):

  • 应用场景。第一种场景称为用户认证,此时用户直接向被保护应用发起请求,经过单点登录系统的认证,得以访问被保护应用上的资源。认证过程涉及三方,即用户、被保护的应用、单点登录系统。其中用户为接受认证的对象。CAS、Kerberos 属于此类。

第二种场景称为第三方应用授权,用户首先访问第三方应用,第三方应用本身不属于被保护的应用,但提供的服务依赖于被保护应用。第三方应用向被保护应用发起请求,要求访问该用户在被保护应用上的资源。用户通过单点登录服务器向第三方应用授权,允许第三方应用的访问请求。认证过程涉及四个实体,即用户、第三方应用、被保护的应用、单点登录系统。其中第三方应用为被授权的对象。OAuth、OpenID 属于此类。

  • 客户端类型。这里主要考虑浏览器客户端和非浏览器客户端。

在 WEB 应用环境下,浏览器客户端是一种广泛存在的客户端。浏览器提供的 cookie 机制可以为实现用户认证提供很大便利。例如 CAS 默认就是利用 cookie 来记忆用户是否在单点登录服务器上认证过。但由于浏览器自身的限制,一般无法提供复杂的加密解密处理能力,而这一能力正是 Kerberos 所必须的。因此 Kerberos 无法适用于浏览器客户端。

  • 离线认证。这一特征指的是 ticket 的验证可以直接在被保护应用上进行,而无需询问单点登录系统。通常这种情况下 ticket 的生成要依赖于非对称加密技术,Openstack Keystone 实现了离线认证。
  • 免疫第三方窃听。通常情况下,当恶意用户截取了 ticket,就可以访问被保护系统,对系统安全造成威胁。但是在 Kerberos 中,ticket 需要结合用户私钥做进一步的加密解密处理才能使用。因此即使被截获也不会对系统安全造成影响。因此 Kerberos 实现了免疫第三方窃听的攻击。
表 1. 常见单点登录实现对比
产品/协议应用场景主要客户端类型支持离线认证免疫第三方窃听CAS用户认证浏览器否否Kerberos用户认证非浏览器否是OAuth应用授权全部否否Openstack Keystone用户认证/应用授权非浏览器是否



用 Java 实现 CMS 数字签名的生成与验证

BouncyCastle 是一个开源的 Java 安全类库(也提供 C# 版本),支持多种安全协议。利用 BouncyCastle 可以很方便的完成 CMS 数字签名的生成和验证任务。

CMS 数字签名的生成

清单 1. 生成 CMS 数字签名

public String sign(X509CertificateHolder signCert, KeyPair signKP) {List certList = new ArrayList();certList.add(signCert);Store certs = new JcaCertStore(certList);CMSTypedData msg = new CMSProcessableByteArray("Hello world!".getBytes());CMSSignedDataGenerator gen = new CMSSignedDataGenerator();ContentSigner sha1Signer = new JcaContentSignerBuilder("SHA1withRSA").setProvider("BC").build(signKP.getPrivate());gen.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder().setProvider("BC").build()).build(sha1Signer, signCert));gen.addCertificates(certs);CMSSignedData sigData = gen.generate(msg, true);String signature = new String(sigData.getEncoded());Return signature;}


在清单 1 所展示的是为“Hello World”这一字符串生成 CMS 数字签名的过程。其中 certs 代表签名方的证书,signKP.getPrivate() 代表签名方的私钥,SHA1withRSA 代表消息的哈希算法是 SHA1,生成公钥私钥的算法是 RSA。这些要素与前文介绍的数字签名的生成过程相吻合。最后得到的 signature 就是 CMS 格式的数字签名字符串。

最后值得注意的是 gen.generate(msg, true),这里的第二个参数代表是否将消息原文封装到签名当中。若这一参数为 false,通常称生成的是 detached signature,表示签名和消息是分开存放的。

CMS 数字签名的验证

清单 2. 验证 CMS 数字签名
public String verify(String signature){   CMSSignedData s = new CMSSignedData(signature.getBytes());   CertStore certs = s.getCertificatesAndCRLs("Collection", "BC");   SignerInformationStore signers = s.getSignerInfos();   boolean verified = false;    for (Iterator i = signers.getSigners().iterator(); i.hasNext(); ) {        SignerInformation signer = (SignerInformation) i.next();        Collection<? extends Certificate> certCollection =         certs.getCertificates(signer.getSID());        if (!certCollection.isEmpty()) {             X509Certificate cert =              (X509Certificate) certCollection.iterator().next();             if (signer.verify(cert.getPublicKey(), "BC")) {                  verified = true;              }     }}  CMSProcessable signedContent = s.getSignedContent() ;  byte[] originalContent = (byte[]) signedContent.getContent();  return new String(originalContent);}

清单 2 展示了如何验证在清单 1 中生成的数字签名。因为 CMS 数字签名支持多个实体对消息进行签名,因此这里可以对每个签名方逐一进行验证。在上一节中已经提到,签名方的证书已经包含在签名中,而证书包含验证签名所需的签名方的公钥。

这里没有展示的一个步骤,是如何验证签名方的证书。通常而言需要确认证书为有公信力的机构所颁发,才能信任该证书所包含的公钥确实由其所声称的实体所有。但数字证书的作用及其验证已经超出本文的范畴,这里不再详述。

SSO 的服务器端实现

服务器端的接口定义

SSO 的服务器端,通常也称为认证服务器 (Authentication Server),其作用是提供负责对所有被保护应用收到的请求进行统一认证。

在定义服务器端对外接口的时候,需要考虑不同类型客户端的需求。对于浏览器客户端来说,服务器需要返回的内容通常是 HTML 格式的网页、Javascript 脚本和 CSS 文件等内容。可以使用重定向技术 (URL Redirection) 将未认证的用户导向到登录页面,在登录完成后,再将用户导向原来访问的页面。同时,还可以使用浏览器提供的 cookie 来存放该用户的 ticket,这样在后续的请求中就可以直接从 cookie 中获得该用户的 ticket。对于这类接口,可以使用传统的 JSP/Servlet 来实现。

但对于非浏览器客户端(应用程序)来说,上述情况就不再适用了。应用程序之间通常以 Web Service 的方式进行交互。交互的内容也不再是网页,而是 XML、JSON 格式的文件。与网页相比,这种类型的文件无需关心如何将内容展示给用户以及如何与用户交互,因此其内容往往更为精简。对于这类接口,目前较为流行的是使用 RESTful(REpresentational State Transfer)Web Service 来实现。

因此,为满足上述两种客户端类型的需求,至少应提供如下对外接口

  • 获取登录页面:展示用户登录页面。
  • 用户登录:响应用户登录页面提交的登录请求。
  • 生成 ticket(REST 接口):接受应用程序发出的认证请求,返回 ticket。
  • 验证 ticket(REST 接口):接受应用程序发出的验证请求,判断 ticket 是否有效,返回 true/false。
  • 删除 ticket(REST 接口):接受应用程序发出的注销请求,让 ticket 失效。

同时,在服务器端内部,提供三个接口以满足上述功能需求,如图 5 所示。

图 5. SSO 服务器端接口
图 5. SSO 服务器端接口

生成 Ticket

前面已经讲到,ticket 实质上就是 CMS 格式的数字签名。前文已经讲述了签名的生成过程,剩下的问题是,用什么作为生成签名的消息原文。对于服务器端来说,不论使用什么消息,都可以完成数字签名的生成和验证,达到用户认证的目的。因此这里主要考虑的是客户端的需求。

绝大多数应用都必须获取用户的身份信息,这也是用户认证的主要目的之一。在单点登录环境下,用户身份信息是由单点登录服务器统一管理的。一种做法是在完成用户认证之后,由被保护的应用向单点登录服务器发起请求来获取该信息。但如果将用户的身份信息作为生成签名的消息原文,就可以避免这一额外的请求。前文已经提到,CMS 数字签名可以将消息原文封装到签名当中。因此在获得 ticket 的同时,就已经获得了与该 ticket 对应的用户身份信息。

除此之外,消息原文还应该包含 ticket 的基本信息,例如生成时间、失效时间、唯一标示符等。完整的消息原文如清单 3 所示。

清单 3. Ticket 消息原文
{ "id" : "2fb7460f-39e6-49fd-8171-7d3e3be67fb3", "expireInMilli" : 3600000, "timestamp" : 1397007699760, "principal" : "admin", "extraInfo" : { "roles" : [ {     "id" : 1000000,     "name" : "用户角色 1"     },{     "id" : 1000001,     "name" : "用户角色 2"     }] }}

SSO 的客户端实现

SSO 客户端的主要作用,是将被保护应用的用户认证交由 SSO 服务器端来执行。

被保护的应用上的资源通常分为两类。一类是公开资源,这类资源任何用户都可以访问,针对这类资源的访问请求无需进行用户认证。另一类资源是私有资源,这类资源包含私密信息,只有特定用户才能访问。当接收到针对私有资源的请求时,必须确认发出该请求的用户身份,才能进一步判断是否可以执行该请求所要求的操作。在无法确认用户身份时,就需要向 SSO 服务器端发起用户认证请求。

Java Servlet 规范中的 Filter 机制可以拦截对 Web 应用的任何请求,非常适合用于实现的 SSO 客户端。如清单 4 所示,在拦截到发往被保护应用的请求时,首先查看该请求所访问的地址是否代表私有资源,如果是则进一步判断当前的 session 是否已经包含认证过的用户信息。如果当前 session 未认证,则需要查看当前请求中是否提供了有效的 ticket。如果没有提供,则根据客户端类型做不同的处理:对于浏览器客户端,转跳到 SSO 服务器进行用户认证;对于非浏览器客户端,返回错误消息提示用户未认证。如果请求中包含了有效的 ticket,就将用户信息存到 session 当中,这样对于来自同一个用户的后续请求可以避免重复认证。

清单 4. 用 Filter 实现 SSO 客户端
@Overridepublic void doFilter(ServletRequest request, ServletResponse response,    FilterChain chain) throws IOException, ServletException {    HttpServletRequest httpReq = (HttpServletRequest) request;    HttpServletResponse httpResp = (HttpServletResponse) response;    HttpSession session = httpReq.getSession(true);    String url = httpReq.getRequestURI();    if (isProtectedUrl(url) && !isSessionAuthenticated(session)) {      String ticket = ... // Get ticket from HTTP request.      boolean isValidTicket = ... // Check whether ticket is valid.      if (!isValidTicket) {           String clientType = httpReq.getHeader(“Client-Type”);          if (isBrowserClient(clientType)) {          // Redirect to SSO server for authentication.          } else {          // Send error response.          }      } else {          markSessionAsAuthenticated(session);      }    }} // Move on chain.doFilter(request, response);

SSO 的交互流程

接下来我们以浏览器客户端为例,通过考察不同场景下 SSO 服务器端与客户端的交互流程,来验证上述 SSO 实现是否达到了单点登录的目的。

假设有两个被保护应用 A 和 B,将用户认证交由 SSO 服务器完成。

一个未经认证的用户向被保护应用 A 发起访问私密资源的请求,需经过如下步骤,如图 6 所示:

  1. 用户通过浏览器向被保护应用 A 发起请求。
  2. 被保护应用 A 接受到请求,发现该用户的 Session 未认证,也未提供有效的 ticket。
  3. 被保护应用 A 向 SSO 服务器发起认证请求。
  4. SSO 服务器发现该用户的 Session 未认证,向用户展示登录页面。
  5. 用户通过浏览器输入用户名密码。
  6. SSO 服务器执行用户登录操作。
  7. 登录成功,生成 ticket,同时将 SSO 服务器上的用户 Session 标志为已认证。
  8. SSO 服务器将用户重定向回被保护应用 A,并提供上一步生成的 ticket。
  9. 被保护应用 A 发现有效的 ticket,将用户的 Session 标识为已认证。
  10. 被保护应用 A 返回私密资源给用户。

在完成上述操作后,同一用户向被保护应用 B 发起请求,需经过如下步骤:

  1. 用户通过浏览器向被保护应用 B 发起请求。
  2. 被保护应用 B 接受到请求,发现该用户的 Session 未认证,也未提供有效的 ticket。
  3. 被保护应用 B 向 SSO 服务器发起认证请求。
  4. SSO 服务器发现该用户的 Session 已认证,无需展示登录页面,直接从 Session 中获取 ticket。
  5. SSO 服务器将用户重定向回被保护应用 B,并提供上一步获得的 ticket。
  6. 被保护应用 B 发现有效的 ticket,将用户的 Session 标识为已认证。
  7. 被保护应用 B 返回私密资源给用户。

对比上述流程可以发现,虽然同一用户访问了两个不同的被保护应用,但只进行了一次认证操作。这里的关键点在于,用户的认证状态是由 SSO 服务器来维护的。当用户首次通过 SSO 服务器完成登录操作后,直到该次登录因超时而失效或是用户执行了登出操作为止,用户在 SSO 服务器上的 Session 始终保持已认证的状态。这样一来,任何被保护应用发起的认证请求都可以直接从 SSO 服务器的用户 Session 里获得有效的 ticket,从而获得该用户的身份信息。因此,上述实现达到了对多个被保护应用实现单点登录的目的。

图 6. SSO 服务器端和客户端交互流程
图 6. SSO 服务器端和客户端交互流程

结束语

本文介绍的基于 CMS 数字签名的 Ticket-based SSO 实现,来自于日常工作中的实践经验。与 CAS 等开源 SSO 实现相比,这种实现方式的主要优点是简单易用,能够同时支持浏览器和非浏览器客户端。另外,使用 CMS 数字签名作为 ticket 使这种方式能够支持离线认证,可以减轻 SSO 服务器的负担。

(本文内容仅代表作者个人观点)










0 0
原创粉丝点击