基于Token的登录流程

一.身份验证(Authentication)

要想区分来自不同用户的请求的话,服务端需要根据客户端请求确认其用户身份,即身份验证

在人机交互中,身份验证意味着要求用户登录才能访问某些信息。而为了确认用户身份,用户必须提供只有用户和服务器知道的信息(即身份验证因子),比如用户名/密码

Web 环境下,常见的身份验证方案分为 2 类:

  • 基于 Session 的验证

  • 基于 Token 的验证

基于 Session 的方案中,登录成功后,服务端将用户的身份信息存储在 Session 里,并将 Session ID 通过 Cookie 传递给客户端。后续的数据请求都会带上 Cookie,服务端根据 Cookie 中携带的 Session ID 来得辨别用户身份

而在基于 Token 的方案中,服务端根据用户身份信息生成 Token,发放给客户端。客户端收好 Token,并在之后的数据请求中带上 Token,服务端接到请求后校验并解析 Token 得出用户身份,过程如下:

token based login

token based login

P.S.用户名/密码属于知识因子,另外还有占有因子和遗传因子:

  • 知识因子:用户登录时必须知道的东西都是知识因子,比如用户名、密码等

  • 占有因子:用户登录时必须具备的东西,比如密码令牌、ID 卡等

  • 遗传因子:个人的生物特征,比如指纹、虹膜、人脸等

P.S.Authentication(验证)与 Authorization(授权)不同,前者验证身份,后者验证权限

二.Token

身份验证中的 Token 就像身份证,由服务端签发/验证,并且在有效期内都具有合法性,认“证”(Token)不认“人”(用户)

Session 方案中用户身份信息(以 Session 记录形式)存储在服务端。而 Token 方案中(以 Token 形式)存储在客户端,服务端仅验证 Token 合法性。这种区别在单点登录(SSO,Single Sign On)的场景最为明显

  • 基于 Session 的 SSO:考虑如何同步 Session 和共享 Cookie。比如登录成功后把响应 Cookie 的 domain 设置为通配兄弟应用域名的形式,并且所有应用都从身份验证服务同步 Session

  • 基于 Token 的 SSO:考虑如何共享 Token。比如进入兄弟应用时通过 URL 带上 Token

Token 相当于加密过的 Session 记录,含有用户 ID 等身份信息,以及 Token 签发时间,有效期等用于 Token 合法性验证的元信息,例如:

{
  // 身份信息
  user_id: 9527,
  // Token元信息
  issued_at: '2012年3月5号12点整',
  expiration_time: '1天'
}
// 加密后
895u3485y3748%^HGdsbafjhb

任何带有该 Token 的请求,都会被服务端认为是来自用户 9527 的消息,直到一天之后该 Token 过期失效,服务端不再认可其代表的用户身份

Token 形式多种多样,其中,JSON Web Token是一种比较受欢迎的 Token 规范

三.JSON Web Token

JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties.

简言之,一种通信规范(简称 JWT),用来安全地表示要在双方之间传递的声明,能够通过 URL 传输

P.S.声明可以是任意的消息,比如用户身份验证场景中的“我是用户 XXX”,好友申请中的“用户 A 添加用户 B 为好友”

Token 格式

JWT 中的 Token 分为 3 部分,Header、Payload 与 Signature,例如:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ

两个.字符隔开三部分,即:

Header.Payload.Signature

含义上,Header表示 Token 相关的基本元信息,如 Token 类型、加密方式(算法)等,具体如下(alg是必填的,其余都可选):

  • typ:Token type

  • cty:Content type

  • alg:Message authentication code algorithm

Payload表示 Token 携带的数据及其它 Token 元信息,规范定义的标准字段如下:

  • iss:Issuer,签发方

  • sub:Subject,Token 信息主题(Sub identifies the party that this JWT carries information about)

  • aud:Audience,接收方

  • exp:Expiration Time,过期时间

  • nbf:Not (valid) Before,生效时间

  • iat:Issued at,生成时间

  • jti:JWT ID,唯一标识

这些字段都是可选的,Payload 只要是合法 JSON 即可

生成

Token 的三部分分别为:

Base64编码的Header.Base64编码的Payload.对前两部分按指定算法加密的结果

例如,对于

// JOSE Header
const header = JSON.stringify({"typ":"JWT", "alg":"HS256"});
// JWT Claims Set
const claims = JSON.stringify({
  "iss":"joe", "exp":1300819380, "http://example.com/is_root":true
});

对 JOSE Header 和 JWT Claims Set 分别进行 Base64 编码得到 JWT Token 中的 Header 与 Payload 部分:

const tokenHeader = Buffer.from(header).toString('base64');
const tokenPayload = Buffer.from(header).toString('base64');

接着把 Header 与 Payload 用.字符连接起来,并通过 HMAC SHA-256 算法(Header 中alg字段指定的加密算法)加密,得到 Signature 部分:

// https://www.npmjs.com/package/jwa
const jwa = require('jwa');
const hmac = jwa('HS256');

const toSign = `${tokenHeader}.${tokenPayload}`;
const tokenSignature = hmac.sign(toSign, 'mySecret');

最后把 Signature 也用.字符连接在最后:

const token = `${toSign}.${tokenSignature}`;

得到结果:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ

P.S.可以通过JWT.IO解析验证该 Token

P.S.注意,Base64 编码的 Header 与 Payload 需要去掉末尾等号(padding trailing equals

传输

服务端生成 Token 之后,放在响应体里传递到客户端

客户端收到之后,将 Token 存放到 LocalStorage/SessionStorage 中,之后请求数据时,将 Token 塞到请求头的Authentication字段里带到服务端:

Authorization: Bearer <jwt_token>

服务端收到数据请求后,从Authorization字段取出 Token,并校验其合法性,进一步解析 Token 内容,获知用户身份

验证

校验 Token 合法性需要确认几件事情:

  • Token 有没有过期

  • 是不是自己签发的

从 Payload 部分解析(直接 Base64 解码)出iatnbfexp三个时间相关的字段,检查是否满足以下关系:

iat签发时间 <= nbf生效时间 < 当前时间 < exp过期时间

接着取出 Token 的前两部分(Header.Payload),再计算一次签名(Signature),看计算结果是否一致

解析

确认 Token 合法之后,只需要简单地对 Payload 进行 Base64 解码,即可得知 Token 携带的数据,例如:

const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ';
const [_, rawPayload,] = token.split('.');
const payload = JSON.parse(new Buffer(rawPayload, 'base64').toString());
// { iss: 'joe',
//   exp: 1300819380,
//   'http://example.com/is_root': true,
//   otherField: 'etc.' }

四.登录

Session 方案中,Cookie 机制让登录变得很简单(客户端几乎无感知),将用户名和密码 Post 过去,返回 200,之后就是已登录用户了

而在 Token 方案中,不一定将 Token 写入 Cookie,比如 SSO 场景下可能直接通过 URL 回传给应用。因此,登录之后的身份凭证对客户端而言是有感知的,客户端需要接收并管理 Token:

  • 存储 Token

  • 请求数据时带上 Token

  • 跳转时将 Token 共享给兄弟应用

  • 用户注销后删掉 Token

同样地,请求数据时也不一定通过 Cookie 携带 Token,而是通过请求头的 Authentication 字段

五.数据操作

发送数据请求时,将 Token 以Bearer <jwt_token>的格式填入 Authorization 字段即可:

Authorization: Bearer <jwt_token>

P.S.Bearer(持有者认证)也叫 Token 认证,类似于我们所熟知的 Basic(基本认证)和 Digest(摘要认证),也是一种基于 HTTP 的认证方式

服务端接到请求会从该字段中取出 Token,并进行校验,校验通过之后将期望的数据或操作结果响应发回客户端

六.注销

在基于 Session 的身份验证中,注销操作就是删掉 Session 中对应的记录。因为在 Session 模式下,用户的登录状态只以服务端 Session 记录为准,所以只要让服务端忘记这段感情,之后就不认得客户端抛来的媚眼了

而 Token 验证则不同,Token 携带着完整的状态信息,服务端的角色更像是负责签发 Token 的认证中心(CA,Certificate Authority),发出去的 Token 在自动过期之前都是合法的,服务端仅通过验证 Token 无法区分合法 Token 与已经作废的(合法 Token)

那么,有办法能让 Token 立即作废吗?

其实,HTTPS 依赖的 SSL 证书也存在这个问题,所以有一份Certificate revocation list(证书吊销列表):

In cryptography, a certificate revocation list (or CRL) is “a list of digital certificates that have been revoked by the issuing certificate authority (CA) before their scheduled expiration date and should no longer be trusted”.

CRL 即证书黑名单,用来记录需要立即作废的合法证书,CA 验证证书之前先检查黑名单,以此区分出已经作废的合法 Token

例如:

// 1.维护黑名单
const tokenBlackLists = [];
function invalidateToken(token) {
  if (!isTokenInvalidated(token)) {
    tokenBlackLists.push(token);
  }
}
function removeInalidatedToken(token) {
  if (isTokenInvalidated(token)) {
    tokenBlackLists.splice(tokenBlackLists.indexOf(token), 1);
  }
}
function isTokenInvalidated(token) {
  return tokenBlackLists.includes(token);
}

// 2.注销时加黑
router.get('/logout', ensureAuthenticated, (req, res, next) => {
  // 废掉token
  invalidateToken(res.locals.token);
  res.status(200).json({
    status: 'success'
  });
});
// 3.过期时去黑
// 4.操作时验证是否已黑
decodeToken(token, (err, payload) => {
  if (err) {
    // remove expired token from blacklist
    removeInalidatedToken(token);
    return res.status(401).json({
      status: 'Token has expired'
    });
  } else {
    // check invalidated token
    if (isTokenInvalidated(token)) {
      return res.status(401).json({
        status: 'Token has been invalidated'
      });
    }
    //...
  }
});

P.S.上例中,黑名单只放在内存中,服务重启时会丢失,比较完备的实现应该是加黑/去黑(即过期)时落库,验证时走内存缓存,重启时读库加载

除黑名单外,还有一些常见策略,如:

  • 删掉客户端 Token:把发出去的 Token 干掉,Token 消失了,登录状态也就不存在了。但服务端仍然认为 Token 合法,不安全

  • 用过期时间很短的 Token,经常轮转:过期时间足够短的话,自动过期就相当于立即过期。但太短又丧失了保持状态的优势

  • Token 带上注销时间:把注销时间也像密码一样存库、校验,像改密码一样让 Token 立即作废。但需要多存/取、校验一个字段,性能相关

必要的话,这 4 种策略可以多管齐下,比如无论使用哪种策略,客户端 Token 都是理应删掉的

P.S.关于如何立即作废 JWT 的更多讨论,见:

七.FAQ

  • JWT 的 Payload 安全吗?

    不安全,仅经 Base64 编码过,相当于明文传输,因此不要携带敏感数据

  • 用户输入的密码需要在客户端加密吗?

    不需要加密,直接明文传,客户端密码安全由 SSL 保证

  • 服务端收到密码应该如何加密?

    一般做法是 Hash 加盐(Adding Salt to Hashing),具体见Adding Salt to Hashing: A Better Way to Store Passwords

参考资料

基于Token的登录流程》上有1条评论

  1. Peter

    你好,请问下Token带上注销时间是什么意思?具体如何实现

    回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code