Shibboleth-IdP 的 OAuth2 对接方案详解

背景

Shibboleth 是一个支持 SAML2.0 的开源 IdP 服务器。

SAML2.0 是一个联邦式认证的标准,简单来说就是能够让应用方——也就是资源提供者(Service Provider,简称 SP)与任意的机构内部认证——也就是身份提供者(Identity Provider)对接时,能够均采用相同的协议标准。这显然能够简化集成,像 AWS,Azure 等云服务商都支持 SAML2.0 方式的机构账号对接。同时这种简化也促进了资源的共享,并形成了各式各样的身份联盟,比如中国的 CARSI[1],澳大利亚的 AAF[2],瑞士的 SWITCHaai[3] 等等,并且通过 eduGAIN[4] 将这些联盟连接起来。

Shibboleth 除了支持 SAML 以外,他在 IdP3 开始支持 CAS 协议[5],并且计划在 IdP4 开始引入 OpenID Connect ,然后在 IdP5 开始稳定支持 OpenID Connect 说实话这是一个槽点(另一个槽点是 CAS 也开始支持 SAML,你说这两拨人真是。。。)。然而如果我们要提供 CAS/OAuth2/OpenID Connect 服务的话,Shibboleth 显然不是优先的选项,SAML2.0 才是选择他的目的。好在 Shibboleth 支持通过一些外部插件的模式来进行认证[6],而 Unicon/shib-cas-authn3[7]是一个集成 CAS[8] 和 Shibboleth 的插件。OAuth2 的集成即基于此插件修改实现。

插件版本

由于 Shibboleth IdP 在 3.4.3 之后修改了一个内部的 API 实现,因此插件版本割裂为 3.3.0 和 3.2.3 两个版本。3.3.0 插件仅支持 Shibboleth IdP 3.4.6,而 3.2.3 仅支持 IdP 3.4.3。OAuth2 插件基于 shib-cas-authn3 插件的 3.2.3 版本修改,暂时不支持 IdP 3.4.6。

修改思路

OAuth2 本身只是授权协议,但是通常我们会将其与认证结合使用。因此包含了认证的 OAuth2 Server 可以与 CAS Server 进行对比。先看流程部分

步骤 CAS OAuth2 差异
1 回调到 CAS 认证 请求授权码,通常回调至认证 相对一致
2 认证完成,获得 ticket 认证完成,获得 code 相对一致
3 code 更换 token OAuth 独有
4 校验 ticket 并获取用户属性 使用 token 调用用户属性接口 相对一致
5 refresh token 可以刷新 token OAuth 独有

可以看到,不考虑 refresh token,把 OAuth 的第二步和第三步连接起来,OAuth 在流程上是可以和 CAS 保持一致的,这意味着插件可以不用进行伤筋动骨的修改,只需要针对性的微调即可。

其他差异:

  • redirect_uri 校验
  • 用户名的判断
  • 属性接口的标准

与 CAS 不同,OAuth2 客户端在使用 code 更换 token 时,还需要附带自己的 redirect_uri,并且 OAuth2 服务端根据标准应该要检验两个 redirect_uri 是否一致。而 Shibboleth IdP 会根据 uri 中的 conversation=e1s1 来区分会话,比如 conversation=e1s1conversation=e1s2 。因此在集成中,IdP 的 redirect_uri 必须动态判定的,不能和 CAS 服务一样静态的指定 /cas/login 来解决。

另一个问题是用户名,对于 CAS 协议而言,用户名是一个标准的字段,他和属性的释放是区分开的。例如这个示例里,用户名已经由 <cas:user>字段标记出来,属性则包含在<cas:attributes>下面:

<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
   <cas:authenticationSuccess>
     <cas:user>username</cas:user>
     <cas:attributes>
       <cas:firstname>John</cas:firstname>
       <cas:lastname>Doe</cas:lastname>
       <cas:title>Mr.</cas:title>
       <cas:email>jdoe@example.org</cas:email>
       <cas:affiliation>staff</cas:affiliation>
       <cas:affiliation>faculty</cas:affiliation>
     </cas:attributes>
     <cas:proxyGrantingTicket>PGTIOU-84678-8a9d...</cas:proxyGrantingTicket>
   </cas:authenticationSuccess>
 </cas:serviceResponse>

而 CAS 插件会讲用户名作为 principalname 传递给 shibboleth,因此修改后的 OAuth2 必须也找到一个唯一确定的用户名字段来作为 principalname,这样才能复用原本 CAS 插件的很多功能。

因此这就引出了第三个问题,属性接口的标准。这实质上是 CAS 协议和 OAuth2 协议的核心分歧。OAuth2 本质上是一个授权协议,他的所有规范都是针对授权过程的(怎么获取 token )对于资源接口没有规定。而 CAS 是一个认证协议,他在认证返回的属性上有很明确的规范。因此这里,我们必须人为的给 OAuth2 Server 返回人员属性的接口进行规定。而这个规定实际上就可以直接参照 OpenID-Connect[9] 内关于 userinfo endpoint 的规范。例如这样的一个返回中,sub 是必须存在的字段,作用类似于 CAS 协议中的 <cas:user>,其他则是可选的,类似于 CAS 协议中 <cas:attributes> 下层中的那些属性

  HTTP/1.1 200 OK
  Content-Type: application/json

  {
   "sub": "248289761001",
   "name": "Jane Doe",
   "given_name": "Jane",
   "family_name": "Doe",
   "preferred_username": "j.doe",
   "email": "janedoe@example.com",
   "picture": "http://example.com/janedoe/me.jpg"
  }

当然这只是一个建议 。在插件中,我们通过配置 shibcas.oauth2principalname = sub 来指定 principalname 所指代的属性字段 ,由用户来选择。接口必须避免层级嵌套,以确保插件能够直接的获取到对应的属性。

最终的 OAuth2 插件源码地址在 https://github.com/shanghai-edu/shib-cas-authn3,选择 tag 3.2.4-oauth 。插件代码基于北京大学赖清楠老师的版本进一步修改优化,特别感谢北京大学 CARSI 项目组团队的前期工作。

插件安装文档详见 CARSI-WiKiSEAC-Document

3.3.0 的 OAuth 版本修改正在工作中,To Be Continued ~~~

实践

常见的坑

实际上正是由于 OAuth2 缺乏 userinfo 的规范,导致 OAuth2 协议对接时,通常需要少量的代码层定制。这反过来导致了一些开发商在提供 OAuth2 产品时的随意和不规范。以下是我碰到过的几个反面例子:

  • Token endpoint 不支持 POST 请求
    OAuth2 的 RFC[10] 明确的要求客户端在请求 token endpoint 创建 token 时应该采用 POST 方法,并将请求参数以 application/x-www-form-urlencoded 编码放在 body 内传输。这很自然,POST 创建资源嘛。但实际上我们实现的往往会选择支持 GET 请求,因为这样会更容易调试,虽然这样就很不 REST 了。小米[11],微信[12] 等大厂的开放平台,也均提供 GET 方式的接口和文档,这显然是出于调试方便的考虑。然而多支持一个 GET 模式,和只支持 GET 显然不是一回事。。。总不能把老实遵从 RFC 的客户端给拒绝了对吧。
  • 注册 redirect_uri 的校验过于严格
    redirect_uri 当然是要校验的。但通常而言,校验到域名,或者校验到 url 路径足矣。要求 url 参数也完全一致的,那其实就根本没在好好校验了,因为这就是在做简单的字符串匹配。在很多时候我们还需要一些动态的参数来提供一些回调页面的个性化支持,这也是 RFC 所允许的。
  • userinfo 接口过于复杂
    OAuth2 是一个授权规范,他对接口设计没有规定,当然可以任意的来发挥。但是当我们把 OAuth2 用于认证时,至少应该在反应用户基本属性——即 userinfo 这个接口上,尽量的简化。建议参照 openid-connect 关于 userinfo 的规范来实现这个接口。

快速测试

oauth-server-lite

oauth-server-lite[13] 是一个轻量级的 OAuth2 服务器,认证部分对接 LDAP ,并将 LDAP 的属性映射为 userinfo 接口。因此可用于 IdP 的 OAuth2 对接测试。

oauth-server-lite 的认证部分支持验证码和IP地址封禁,以对抗暴力破解。因此也将其直接和 IdP 打包在一起部署,作为 IdP 的安全加固手段之一应用。

oauth-server-lite 的 /oauth/v1/userinfo 接口实现了 OpenID-Connect 的规范,它会将用户名作为 sub 字段默认插入,并将 ldap 返回的属性作为其他字段输出。ldap 的多值部分以 ; 连接为字符串,例如:

{
  "cn": "小冯冯", 
  "uid": "11116666", 
  "memberOf": "教职工", 
  "mail": "qfeng@exampe.org", 
  "sub": "11116666"
}

oauth-server-lite 的事情,留到下回再说吧

以上

参考文献

[1] CERNET Authentication and Resource Sharing Infrastructure
[2] Australian Access Federation
[3] SWITCH Authentication and Authorization Infrastructure
[4] eduGAIN
[5] Shibboleth Implemented Protocols and Profiles
[6] Shibboleth RemoteUserAuthnConfiguration
[7] A Shibboleth IdP v3.X plugin for authentication via an external CAS Server
[8] CAS Enterprise Single Sign-On
[9] OpenID Connect Core 1.0 incorporating errata set 1
[10] OAuth2 RFC
[11] 小米开发平台
[12] 微信开发平台
[13] shanghai-edu/oauth-server-lite

转载授权

CC BY-SA