通常为了弄清楚一个概念,我们需要掌握十个概念。在判断 JWT (Json Web Token) 是否能代替 session 管理之前,我们要了解什么是 token,以及 access token 和 refresh token 的区别;了解什么是 OAuth,什么是 SSO,SSO 下不同策略 OAuth 和 SAML 的不同,以及 OAuth 与 OpenID 的不同,更重要的是区分 authorisation 和 authentication;最后我们引出 JSON WEB TOKEN,聊聊 JWT 在 session 管理方面的优势和劣势,同时尝试解决这些劣势,看看成本和代价有多少

本文关于 OAuth 授权和 API 调用实例都来自 Google API。

关于 Token

token 即使是在计算机领域中也有不同的定义,这里我们说的token,是指访问资源的凭据。例如当你调用Google API,需要带上有效 token 来表明你请求的合法性。这个 token 是 Google 给你的,这代表 Google 给你的授权使得你有能力访问 API 背后的资源。

请求 API 时携带 token 的方式也有很多种,通过 HTTP Header 或者 url 参数 或者 google 提供的类库都可以:

// HTTP Header:
GET /drive/v2/files HTTP/1.1
Authorization: Bearer <token>
Host: www.googleapis.com/

// URL query string parameter
GET https://www.googleapis.com/drive/v2/files?token=<token>

// Python:
from googleapiclient.discovery import build
drive = build('drive', 'v2', credentials=credentials)

更具体的说,上面用于调用 API 的 token 我们称为细分为 access token。通常 access token 是有有效期限的,如果过期就需要重新获取。那么如何重新获取?现在我们要让时光倒流一会,回顾第一次获取 token 的流程是怎样的:

  1. 首先你需要向 Google API 注册你的应用程序,注册完毕之后你会拿到认证信息(credentials)包括 ID 和 secret。不是所有的程序类型都有 secret。
  2. 接下来就要向 Google 请求 access token。这里我们先忽略一些细节,例如请求参数(当然需要上面申请到的 secret)以及不同类型的程序的请求方式等。重要的是,如果你想访问的是用户资源,这里就会提醒用户进行授权。
  3. 如果用户授权完毕。Google 就会返回 access token。又或者是返回授权代码(authorization code),你再通过代码取得 access token
  4. token 获取到之后,就能够带上 token 访问 API 了

流程如下图所示: google token flow

注意在第三步通过 code 兑换 access token 的过程中,Google 并不会仅仅返回 access token,还会返回额外的信息,这其中和之后更新相关的就是 refresh token

一旦 access token 过期,你就可以通过 refresh token 再次请求 access token。

以上只是大致的流程,并且故意省略了一些额外的概念。比如更新 access token 当然也可以不需要 refresh token,这要根据你的请求方式和访问的资源类型而定。

这里又会引起另外的两个问题:

  1. 如果 refesh token 也过期了怎么办?这就需要用户重新登陆授权了
  2. 为什么要区分 refresh token 和 access token ?如果合并成一个 token 然后把过期时间调整的更长,并且每次失效之后用户重新登陆授权就好了?这个问题会和后面谈的相关概念有关,稍后再回答

OAuth

从获取 token 到使用 token 访问接口。这其实是标准的 OAuth 2.0 机制下访问 API 的流程。这一节我们聊一聊 OAuth 里外相关的概念,更深入的理解 token 的作用。

SSO (Single sign-on)

通常公司内部会有非常多的工具平台供大家使用,比如人力资源,代码管理,日志监控,预算申请等等。如果每一个平台都实现自己的用户体系的话无疑是巨大的浪费,所以公司内部会有一套公用的用户体系,用户只要登陆之后,就能够访问所有的系统。这就是单点登录(SSO: Single Sign-On)

SSO 是一类解决方案的统称,而在具体的实施方面,我们有两种策略可供选择:1) SAML 2.0 ; 2) OAuth 2.0。接下来我们区别这两种授权方式有什么不同。

但是在描述不同的策略之前,我们先叙述几个共有的,并且相当重要的概念。

Authentication VS Authorisation

  • Authentication: 身份鉴别,以下简称认证
  • Authorisation: 授权

认证的作用在于认可你有权限访问系统,用于鉴别访问者是否是合法用户;而授权用于决定你有访问哪些资源的权限。大多数人不会区分这两者的区别,因为站在用户的立场上。而作为系统的设计者来说,这两者是有差别的,这是不同的两个工作职责,我们可以只需要认证功能,而不需要授权功能,甚至不需要自己实现认证功能,而借助 Google 的认证系统,即用户可以用 Google 的账号进行登陆。

Authorization Server/Identity Provider(IdP) VS Service Provider(SP)/Resource Server

把负责认证的服务称为 Authorization Server 或者 Identity Provider,以下简称 IdP;而负责提供资源(API调用)的服务称为 Resource Server 或者 Service Provider,以下简称 SP

SMAL 2.0

下图是 SMAL 2.0 的流程图,看图说话

smal flow

  • 还未登陆的用户打开浏览器访问你的网站(SP,以下都简称 SP),网站提供服务但是并不负责用户认证。
  • 于是 SP 向 IdP 发送了一个 SAML 认证请求,同时 SP 将用户浏览器重定向到 IdP 。
  • IdP 在验证完来自 SAML 的请求无误之后,在浏览器中呈现登陆表单让用户进行填写用户名和密码进行登陆
  • 一旦用户登陆成功,IdP 会生成一个包含用户信息(用户名或者密码)的 SAML token (SAML token 又称为 SAML Assertion,本质上是 XML 节点),IdP 向 SP 返回 token, 并且将用户重定向到 SP (token 的返回是在重定向步骤中实现的,下面会详细说明)
  • SP 对拿到的 token 进行验证,并从中解析出用户信息,例如他们是谁以及他们的权限有哪些。此时就能够根据这些信息允许用户访问我们网站的内容了

当用户在 IdP 登陆成功之后,IdP 需要将用户再次重定向至 SP 站点,这一步通常有两个办法:

  • HTTP 重定向(HTTP Redirect):这并不推荐,应为重定向的 URL 长度有限,无法携带更长的信息,比如 SMAL Token
  • HTTP POST 请求:这个是更常规的做法,当用户登陆完毕之后渲染出一个表单,用户点击后向 SP 提交 POST 请求。又或者可以使用 Javascript 向 SP 发出一个 POST 请求

如果你的应用是基于 web,那么以上的方案没有任何问题。但如果你开发的是一个 iOS 或者 Android 的手机应用,那么问题就来了:

  • 用户在 iPhone 上打开应用,此时用户需要通过 IdP 进行认证
  • 应用跳转至 Safari 浏览器,在登陆认证完毕之后,需要通过 HTTP POST 的形式将 token 返回至手机应用

虽然 POST 的 url 可以拉起应用,但是手机应用无法解析 POST 的内容,我们也就无法读取 SAML Token

当然还是有办法的,比如在 IdP 授权阶段不跳转至系统的 Safari 浏览器,在内嵌的 webview 中解决,在想方设法从 webview 中提取 token,或者利用代理服务器。但无论如何,SAML 2.0 并不适用于当下跨平台的场景,这也许与它产生的年代也有关系,它诞生于 2005 年,在那个时刻 HTTP POST 确实是最好的选择方案

OAuth 2.0

我们先简单了解 SSO 下的 OAuth 2.0 的流程。

oauth flow

  • 用户通过客户端(可以是浏览器也可以是手机应用)想要访问 SP 上的资源,但是 SP 告诉用户需要进行认证,将用户重定向至 IdP
  • IdP 向用户询问 SP 是否可以访问用户信息,如果用户同意,IdP 向客户端返回 access code
  • 客户端拿 code 向 IdP 换 access token,并拿着 access token 向 SP 请求资源
  • SP 接受到请求之后拿着附带 token 向 IdP 验证用户的身份

那么 OAuth 是如何避免 SAML 流程下无法解析 POST 内容的信息的呢?用户从 IdP 返回客户端的方式是通过 URL 重定向,这里的 URL 允许自定义schema,所以即使在手机上也能拉起应用;另一方面因为 IdP 向客户端传递的是 code,而不是 XML 信息,所以 code 可以很轻易的附着在重定向 URL 上进行传递

但以上的 SSO 流程体现不出 OAuth 的本意。OAuth 的本意是一个应用允许另一个应用在用户授权的情况下访问自己的数据,OAuth 的设计本意更倾向于授权而非认证(当然授权用户信息就间接实现了认证), 虽然 Google 的 OAuth 2.0 API 同时支持授权和认证。所以你在使用 Facebook 或者 Gmail 账号登陆第三方站点时,会出授权对话框告诉你第三方站点可以访问你的哪些信息,需要征得你的同意:

OAuth2Consent

在上面 SSO 的 OAuth 流程中涉及三方角色: SP, IdP 以及 Client。但在实际工作中 Client 可以是不存在的,例如你编写了一个后端程序定时的通过 Google API 从 Youtube 拉取最新的节目数据,那么你的后端程序需要得到 Youtube 的 OAuth 授权即可。

OAuth VS OpenId

如果你有留心的话,你会在某些站点看到允许以 OpenID 的方式登陆,其实也就是以 Facebook 账号或者 Google 账号登陆站点:

openid

这听上去似乎和 OAuth 很像。但本质上来说它们是截然不同用户的两个东西:

  • OpenID 只用于身份认证(Authentication),允许你以同一个账户在多个网站登陆。它仅仅是为你的合法身份背书,当你以 Facebook 账号登陆某个站点之后,该站点无权访问你的在 Facebook 上的数据
  • OAuth 用于授权(Authorisation),允许被授权方访问授权方的用户数据

Refresh Token

现在我们可以回答本篇第一小节的那个问题了:为什么我们需要 refresh token?

这样的处理是为了职责的分离:refresh token 负责身份认证,access token 负责请求资源。虽然 refresh token 和 access token 都由 IdP 发出,但是 access token 还要和 SP 进行数据交换,如果公用的话这样就会有身份泄露的可能。并且 IdP 和 SP 可能是完全不同的服务提供的。而在第一小节中我们之所以没有这样的顾虑是因为 IdP 和 SP 都是 Google

refresh token

总结

这一小节我们重点了解了 OAuth,以及关于身份认证和授权的区别。现在我们可以把上一小节的知识关联起来,也更加能理解 token:token 其实是为 OAuth 服务的,它是访问数据的一把钥匙。接下来我们看看这把钥匙的另一种形态:Json Web Token, 简称 JWT

JWT

感性认识

首先我们需要从感性上认识 JWT。本质上来说 JWT 也是 token,正如我们第一小节学习到的,它是访问资源的凭证

Google 的一些 API 诸如 Prediction API 或者 Google Cloud Storage,是不需要访问用户的个人数据的,因而不需要经过用户的授权这一步骤,应用程序可以直接访问。就像上一节 OAuth 中没有 Client 没有参与的流程类似。这就要借助 JWT 完成访问了, 具体流程如下

serviceaccount

  • 首先你需要再 Google API 上创建一个服务账号(service account)
  • 获取服务账号的认证信息(credential),包括邮箱地址,client ID,以及一对公钥/私钥
  • 使用 client ID 和私钥创一个签名的 JWT,然后将这个 JWT 发送给 Google 交换 access token
  • Google 返回 access token
  • 程序通过 access token 访问 API

甚至你可以不需要向 Google 索要 access token,而是携带 JWT 作为 HTTP header 里的 bearer token 直接访问 API 也是可以的。我认为这才是 JWT 的最大魅力

理性认识

JWT 顾名思义,它是 JSON 结构的 token,由三部分组成:1) header 2) payload 3) signature

header

header 用于描述元信息,例如产生 signature 的算法:

{
    "typ": "JWT",
    "alg": "HS256"
}

其中alg关键字就指定了使用哪一种哈希算法来创建 signature

payload

payload 用于携带你希望向服务端传递的信息。你既可以往里添加官方字段(这里的“字段” (field) 也可以被称作“声明” claims),例如iss(Issuer), sub(Subject), exp(Expiration time),也可以塞入自定义的字段,比如 userId:

{
    "userId": "b08f86af-35da-48f2-8fab-cef3904660bd"
}

signature

signature 译为「签名」

创建签名要分以下几个步骤:

  • 你需要从接口服务端拿到密钥,假设为secret
  • header进行 base64 编码,假设结果为headerStr
  • payload进行 base64 编码,假设结果为payloadStr
  • headerStrpayloadStr.字符串拼装起来成为字符data
  • datasecret作为参数,使用哈希算法计算出签名

如果上述描述还不直观,用伪代码表示就是:

// signature algorithm
data = base64urlEncode( header ) + “.” + base64urlEncode( payload )
signature = Hash( data, secret );

假设我们的原始 JSON 结构是这样的:

// Header
{
  "typ": "JWT",
  "alg": "HS256"
}
// Payload:
{
  "userId": "b08f86af-35da-48f2-8fab-cef3904660bd"
}

如果密钥是字符串secret的话,那么最终 JWT 的结果就是这样的

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJiMDhmODZhZi0zNWRhLTQ4ZjItOGZhYi1jZWYzOTA0NjYwYmQifQ.-xN_h82PHVTCMA9vdoHrcZxH-x5mb11y1537t3rGzcM

你可以在 jwt.io 上验证这个结果

JWT 究竟带来了什么

JWT 的目的不是为了隐藏或者保密数据,而是为了确保数据确实来自被授权的人创建的(不被篡改)

回想一下,当你拿到 JWT 时候,你完全可以在没有 secret 的情况下解码出 header 和 payload,因为 header 和 payload 只是经过了 base64 编码(encode)而已,编码的目的在于利于数据结构的传输。虽然创建 signature 的过程近似于加密 (encrypt),但本质其实是一种签名 (sign) 的行为,用于保证数据的完整性,实际上也并且并没有加密任何数据

关于 Encoding, Encryption, Hashing之间的差异,可以参考这篇文章:Encoding vs. Encryption vs. Hashing vs. Obfuscation

用于接口调用

接下来在 API 调用中就可以附上 JWT (通常是在 HTTP Header 中)。又因为 SP 会与程序共享一个 secret,所以后端可以通过 header 提供的相同的 hash 算法来验证签名是否正确,从而判断应用是否有权力调用 API

有状态的对话

因为 HTTP 是无状态的,所以客户端和服务端需要解决的如何让之间的对话变得有状态。例如只有是登陆状态的用户才有权限调用某些接口,那么在用户登陆之后,需要记住该用户是已经登陆的状态。常见的方法是使用 session 机制

常见的 session 模型是这样工作的:

how session work

  • 用户在浏览器登陆之后,服务端为用户生成唯一的 session id,存储在服务端的存储服务(例如 MySql, Redis)中
  • 该 session id 也同时返回给浏览器,以 SESSION_ID 为 KEY 存储在浏览器的 cookie 中
  • 如果用户再次访问该网站,cookie 里的 SESSION_ID 会随着请求一同发往服务端
  • 服务端通过判断 SESSION_ID 是否已经在 Redis 中判断用户是否处于登陆状态

相信你已经察觉了,理论上来说,JWT 机制可以取代 session 机制。用户不需要提前进行登陆,后端也不需要 Redis 记录用户的登陆信息。客户端的本地保存一份合法的 JWT, 当用户需要调用接口时,附带上该合法的 JWT,每一次调用接口,后端都使用请求中附带的 JWT 做一次合法性的验证。这样也间接达到了认证用户的目的

然而 JWT 真的能取代 session 机制吗?这么做有哪些好处和坏处?这些问题我们留在下一篇再讨论

参考资料集合

https://www.site2share.com/folder/20020530