最近开发的项目,需要对接公司已有的 CAS 服务,之前的项目都是用的传统模式(后端渲染或前后端不分离)来开发的,所以后端可以很容易的实现控制跳转,完成校验,写入 Cookies 等逻辑。在传统的开发模式下,使用 session-cookie 可以保证接口安全,在没有登录的情况下访问关键数据会跳转到登录界面或者请求失败。而使用 REStful API 之后,session-cookie 存在以下 3 个问题:
- 客户端除了浏览器,可能还包括手机端 APP,对于手机端而言,管理 cookie 是一件麻烦的事情
- RESTful 风格的 API 不建议使用 cookie
- Cookie 本身有一个缺陷,不能跨域
解决这个问题的方案是让前端传数据时,在 URL 参数中或者 header 中携带一个 参数,我们成这个参数为 token,后端通过这个 token 来判断用户身份,这样可以免去对 Cookies 的管理。
顺着这个思路,我们很容易想到一种方案,就是后端维护一个 Map
,key
值为 token
,value
为用户信息,这样只要用户登录时生成这个 key
后,放到 Map
中,然后将 key
返回给前端就行了,但是这样做有个很严重的问题,Map
是在内存中的,如果后端服务为集群时,还需要做 key
同步,非常麻烦,当然也有人会提出可以将后端生成的 token
和对应的用户信息放在 键-值 数据库中,这样就不用考虑同步问题了,当然这样做没有什么问题,但是会额外引入基础组件(我们现在做第一版,为了快速开发,不打算引入太多的组件),而且还要保证键值数据库的高可用性。
这里我使用了一个更加优雅的方案:JSON Web Token
JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用 JWT 在用户和服务器之间传递安全可靠的信息。
JWT的组成:
一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。
载荷(Payload):
Payload 是 JWT 存储信息的部分。Payload 也是一个 json 数据,每一个 json 的 key-value 称为一个声明。
我们将用户信息描述成一个 json 对象。其中添加了一些其他的信息,帮助今后收到这个 JWT 的服务器理解这个 JWT 。
1 | { |
这里面的前五个字段都是由JWT的标准所定义的
- iss: 该JWT的签发者
- sub: 该JWT所面向的用户
- aud: 接收该JWT的一方
- exp(expires): JWT 的过期时间,是一个 unxi 时间戳
- iat(issued at): JWT 的签发时间,是一个 unix 时间戳
上面这个 payload 中,user_id
和 username
为自定义声明。
将上面的 json 对象进行base64编码可以得到下面的字符串。这个字符串我们将它称作JWT的Payload(载荷)。
ewogICAgImlzcyI6ICJQYW5tYXggSldUIiwKICAgICJpYXQiOiAxNDQxNTkzNTAyLAogICAgImV4cCI6IDE0NDE1OTQ3MjIsCiAgICAiYXVkIjogInd3dy5leGFtcGxlLmNvbSIsCiAgICAic3ViIjogImpyb2NrZXRAZXhhbXBsZS5jb20iLAogICAgInVzZXJfaWQiOiAxLAogICAgInVzZXJuYW1lIjogImppYXBhbiIKfQ==
注:Base64是一种编码,也就是说,它是可以被翻译回原来的样子来的。它并不是一种加密过程。
头部(Header)
WT还需要一个头部,头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象。
1 | { |
在这里,我们说明了这是一个 JWT,并且我们所用的签名算法(后面会提到)是 HS256 算法。
对它也要进行Base64编码,之后的字符串就成了 JWT 的 Header(头部)。
ewogICJ0eXAiOiAiSldUIiwKICAiYWxnIjogIkhTMjU2Igp9
签名(签名)
将上面的两个编码后的字符串都用句号.连接在一起(头部在前),就形成了
ewogICJ0eXAiOiAiSldUIiwKICAiYWxnIjogIkhTMjU2Igp9.ewogICAgImlzcyI6ICJQYW5tYXggSldUIiwKICAgICJpYXQiOiAxNDQxNTkzNTAyLAogICAgImV4cCI6IDE0NDE1OTQ3MjIsCiAgICAiYXVkIjogInd3dy5leGFtcGxlLmNvbSIsCiAgICAic3ViIjogImpyb2NrZXRAZXhhbXBsZS5jb20iLAogICAgInVzZXJfaWQiOiAxLAogICAgInVzZXJuYW1lIjogImppYXBhbiIKfQ==
最后,我们将上面拼接完的字符串用 HS256 算法进行加密。在加密的时候,我们还需要提供一个密钥(secret)。加密之后,得到一串加密字符串,最后把这串加密字符串也是用 . 拼接在 header.payload 后面,形成完整的 JWT。
最终生成的JWT格式如下:
xxx.yyy.zzz
这里签名的目的是为了保证 payload 数据的完整性。如果 JWT 在传输过程中被第三方劫持,中间人对 header.payload 进行修改,并且使用自己的密钥重新签名。服务端收到中间人修改过的 JWT,使用自己的密钥对 header.payload 进行再次加密,由于中间人和服务端使用的是不同的密钥签名,所以服务端再次加密的结果肯定和中间人加密的结果不一致,由此可以断定该 JWT 被恶意篡改。
经过上边的介绍,我们可以看出 JWT 中 Payload 的信息是可以解码会明文的,也就是说信息会泄露,所以 JWT 中不应该存放任何敏感信息,用于登录时我们只需放入用户ID或者用户名就可以了,不要把身份号或者密码等信息放入JWT中,应该让后端拿到用户ID或者用户名后进行查询得到身份证号等隐私信息。
好的,以上就是JWT的科普部分,下边介绍下我这边CAS对接实现方案。
我单独写了一个服务,命名为 hodor(hold the door),作为一个中间层来验证 CAS ticket 并且根据用户信息生成 JWT Token。
我们用 zuul 作为网关服务,当请求过来后,网关判断有没有携带 token,并且判断 token 的有效性,我们需要把网关里验证 jwt 的密钥和 hodor 中生成 jwt 的密钥设置为相同的字符串就可以完成验证工作。
当网关验证 token 没有被篡改并且还在有效期内后,从 Payload 中取出我们需要的信息,将这些信息明文放在 header 中继续往后请求各个应用,对于应用来说从网关过来的请求是可信的,直接从头中取出相应的用户名或者ID就行了。这里有一个坑,就是网关将信息放入 header 的时候,只能传 ASCII 编码字符串,我们的 CAS 返回用户信息时会同时返回中文姓名,所以中文在传入 header 时,需要做 urlencode 处理,同时应用内接受时也需要做 urldecode。
如果没有携带 token 或 token 无效,网关会返回 HTTP 401 错误,前端收到这个返回码后,会跳转到 CAS 认证地址,让用户来登录。同时 service 是前端配置好的一个地址,当用户登录成功后,会回到前端配置好那个地址,前端拿到 CAS 给的 ticket 后,用这个 ticket 和申请 ticket 时的 service 来请求我写的 hodor 服务,hodor根据 ticket 和service 来完成ticket验证工作,获取用户信息,生成jwt,返回给前端,前端保存这个 token,再之后的请求中携带这个 token 来访问就行了。