发布于

Cookie、Session 和 JWT

作者

HTTP 作为应用层协议在 1990 年提出,其设计初衷非常纯粹:提供一种通用的应用层协议规范,用于在互联网上传输和访问网络资源(主要是 HTML)以供客户端(在当初主要是浏览器1)和服务端之间进行交互。HTTP Cookie 则是由网景公司在 1994 年根据实际应用需求提出,主要目的是在无状态的 HTTP 协议中引入状态信息(用户登陆信息、访问记录等)。Cookie 只存在于浏览器中,并只供浏览器使用。服务端根据具体应用需求,在发给客户端的 HTTP 响应头中通过 Set-Cookie 字段配置对应的键值对信息(也就是 Cookie)存储在客户端2。浏览器则会在发送每个 HTTP 请求前根据本地存储的 Cookie 对应的属性(域名信息,过期时间等)信息来将满足要求的 Cookie 放入到 HTTP 请求头中,随着 HTTP 请求送往服务器3

下面是一个包含 Cookie 设置的 HTTP 响应头示例,服务端可以在 HTTP 响应头中通过 Set-Cookie 指定想要客户端去存储的 Cookie 键值信息:

HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: cookie_name1=cookie_value1
Set-Cookie: cookie_name2=cookie_value2

客户端则在其发出的 HTTP 请求头中通过 Cookie 指明该请求所包含的 Cookie 信息:

GET /some_resource.html HTTP/1.1
Host: huohaodong.com
Cookie: cookie_name1=cookie_value1; cookie_name2=cookie_value2

由于浏览器发送的每个 HTTP 请求头中都可能包含 Cookie 信息,为了防止应用程序滥用 Cookie 导致浏览器性能下降,RFC 标准以及各家浏览器都分别对 Cookie 的大小和数量进行了限制4

浏览器单个 Cookie 大小限制每个域名下所有 Cookie 的大小总和限制
Chrome4096 bytes无明确限制
Opera4096 bytes4096 bytes
Edge4096 bytes无明确限制
Safari4096 bytes4096 bytes

这里应当注意的是,这里提到的 Cookie 的大小限制是对于某个域名下的所有 Cookie 而言的。比如在 Safari 浏览器下对于域名 subdomain.huohaodong.com,我们可以同时设置 2 个大小为 2048 字节的 Cookie,或者 4 个大小为 1024 大小的 Cookie,这些 Cookie 对应的键值数据的大小总和不能超过 Safari 浏览器单个域名 Cookie 大小总和 4096 bytes 的限制5。如果浏览器对于单个域名下所有 Cookie 大小总和没有作出明确限制(比如 Chrome),则单个域名下可以创建多个大小总和超过 4096 bytes 的 Cookie。

不同的浏览器在不同的版本对于 Cookie 有不同的实现,一些遥测脚本就利用了不同浏览器的 Cookie 特点以区分用户的使用的是什么浏览器6。如果想要在客户端保存更多的数据,可以考虑使用浏览器提供的 sessionStorage 或 localStorage 机制7

此外,我们还可以通过设置 Cookie 的 HttpOnly 属性来保证浏览器本地存储的 Cookie 信息不会被恶意脚本访问到,以及 Secure 属性确保 Cookie 只能通过 HTTPS 协议传输,二者用以缓解引入 Cookie 导致的安全问题。

Session

Session 是一种用于在无状态的 HTTP 协议中维护 Client-Server 连接信息的技术,HTTP 协议规范并没有对 Session 进行定义。只要能够赋予服务端区分每个请求对应的客户端的能力,就可以认为实现了一种 Session 机制,因而 Session 的实现方式非常灵活。

Session 最常见的一种实现方式是利用 Cookie 机制:服务端将客户端请求对应的连接信息通过某种机制(比如为每个新的用户请求生成一个对应的 UUID 作为标识)保存在本地,并通过在 HTTP 响应头中设置 Cookie 以将 Session 凭证存储在浏览器客户端中,服务端则通过解析客户端发来的 HTTP 请求头中 Cookie 对应的 Session 信息来确认当前请求对应的 Client-Server 连接信息。

通过 Cookie 实现 Session 机制

以 Java Servlet 容器(Tomcat、Jetty 等)为例,浏览器与 Servlet 容器建立 TCP 连接后,如果服务端调用了 request.getSession() 方法,则 Servlet 容器会为本次 Client-Server 连接创建一个名为 JSESSION_ID 的 Cookie 发送给客户端,并将该客户端对应的 JSESSION_ID 信息保存到 Servlet 容器本地以供后续区分该客户端连接。

JSON Web Token

基本概念

JSON Web Token(JWT)是由 RFC 7519 规定的认证标准,其主要定义了一套声明(Claim)格式规范,可以用于实现基于声明的认证(Claims-based identity)。

基于声明的认证的工作方式非常直观,以 GitHub 为例,GitHub 账户可以生成一个或多个不同操作细粒度的 Personal Access Token(PAT),在生成 PAT 的时候我们可以选择对 PAT 的能力进行限制,比如通过该 PAT 登陆的用户是否可以访问私有仓库、是否可以对仓库进行操作、是否可以读写仓库信息等。生成 PAT 后,就可以将 PAT 作为密码来使用了,用户通过 PAT 登陆 GitHub 后,只能够进行该 PAT 在创建时所允许的部分操作,用户在进行操作时(发起 API 请求)会附带对应的 PAT 信息,GitHub 服务端 API 会首先验证该 PAT 是否合法(是否被恶意篡改),进而对该 PAT 所允许的操作集合与当前请求操作进行对比验证,如果操作不合法则会返回错误信息。

JWT 就是用于定义 Token 格式的一套规则,其由三部分 JSON 格式的数据组成,分为令牌头(Header)、令牌负载 / 令牌体(Payload)以及令牌签名(Signature),三部分内容在传输时均采用 Base64URL 编码并用 . 作为分隔符。

Header 定义了描述该 Token 的元数据信息(Metadata),如下所示:

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

其中 alg 指定了该 Token 生成 Signature 时所采用的消息摘要算法,默认为 HMAC SHA256,typ 则用来表明令牌类型,这里为 JWT。

Payload 则是该 Token 实际所携带的数据信息。JWT 提供了如下 7 个默认字段(Registered claims)供我们使用8

iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号

一个典型的 Payload 如下所示:

{
  "sub": "JWT 介绍",
  "name": "Huo Haodong",
  "iat": 20221009,
  "jti": 10086,
  "opr": "read"
}

如果觉得默认字段不够用,我们还可以自定义私有字段(Private claims)来使用,比如上述示例的 opr 字段。私有字段的正常使用需要客户端和服务端 API 都明白字段的含义并进行解析处理。此外,我们也可以向 IANA 注册自己的私有字段以避免字段名与其他服务提供的接口字段产生冲突。

Signature 部分则根据 Header 指定的算法,将 Base64URL 编码后的 Header、Payload 结合 Signature 算法所需的密钥(Secret)生成。如下所示:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

最后将用 Base64URL 编码产生的 Header、Payload 以及通过上述方式生成的 Signature 这三个部分用 . 连接后就生成了一个 JWT Token,如下所示:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJKV1Qg5LuL57uNIiwibmFtZSI6Ikh1byBIYW9kb25nIiwiaWF0IjoyMDIyMTAwOSwianRpIjoxMDA4Niwib3ByIjoicmVhZCJ9.Sng-wOqPf3-e9N7bw1zCAoGaAjsLgvEz-v8Z4SyQDiM

认证原理

JWT 认证基本流程

服务端对于每个 HTTP 请求中的 JWT,首先会通过 Base64URL 解码 JWT 的 Header 部分,并通过 alg 字段得到该 Token 所采用的 Signature 生成算法类型,接着通过 typ 字段查看该 Token 是否是 JWT。之后将原始的、经过 Base64URL 编码以及 . 连接的部分连同存储在服务端的密钥(secret)一起传入 alg 字段对应的 Signature 生成算法中。最后将生成的 Signature 与 Token 的 Signature 进行对比,如果二者相同则表明该 Token 是合法的。

Signature 的 secret 可以认为是蟹老板的蟹黄堡秘方,JWT 整体可以看做是海绵宝宝根据秘方做出的蟹黄堡。痞老板由于不知道蟹黄堡秘方,其仿制的海霸堡自然不能完全复现蟹黄堡的风味。

值得一提的是,上图只是对 JWT 认证的一种逻辑抽象,Authorization Server 和 API Server 可能是同一台服务器。如果 API Server 本身实现了 JWT 签发的逻辑,且应用整体的框架中并没有引入单独的 Authorization Server,那么上图中的 Authorization Server 部分的逻辑可以和 API Server 合并。此外,客户端发起的 HTTP 请求到达 API Server 前还可能经过了反向代理服务器、API 网关等。

具体实现

JWT 的实现需要考虑下面几个问题:

  1. 服务端如何实现 JWT 的签发和解析?
  2. 客户端获取到 JWT 后,应该存储在哪里?
  3. JWT 应该放到 HTTP 请求报文的哪个部分?
  4. 客户端如何做到每次发起 HTTP 请求时都附加 JWT?

服务端如何实现 JWT 的签发和解析?

几乎所有的主流编程语言都有对应的 JWT 库供使用,想要实现 JWT 签发和解析逻辑只需要参考对应语言的 JWT 开发库文档即可。这里以 Java 的 jjwt 库为例。

首先在 Maven 中引入 jjwt 库的依赖:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

假设我们要创建如下所示的 JWT:

header: {
  "alg": "HS256",
  "typ": "JWT"
}
body: {
  "sub": "JWT 介绍",
  "name": "Huo Haodong",
  "iat": 当前时间,
  "jti": "10086",
  "opr": "read"
}

创建 JWT:

Key secret = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String token = Jwts.builder()
    .setHeaderParam("typ", "JWT")
    .setSubject("JWT 介绍")
    .claim("name", "Huo Haodong")
    .setIssuedAt(new Date())
    .setId("10086")
    .claim("opr", "read")
    .signWith(secret)
    .compact();

验证和解析 JWT:

Jws<Claims> claims = Jwts.parserBuilder()
    .setSigningKey(secret)
    .build()
    .parseClaimsJws(token);

注意这里要确保生成和解析操作所使用的 secret 是同一个。

完整代码如下:

package com.huohaodong.blog.jwt.example;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;

import java.security.Key;
import java.util.Date;

public class Main {
    public static void main(String[] args) {
        Key secret = Keys.secretKeyFor(SignatureAlgorithm.HS256);
        String token = Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject("JWT 介绍")
                .claim("name", "Huo Haodong")
                .setIssuedAt(new Date())
                .setId("10086")
                .claim("opr", "read")
                .signWith(secret)
                .compact();
        Jws<Claims> claims = Jwts.parserBuilder()
                .setSigningKey(secret)
                .build()
                .parseClaimsJws(token);
        System.out.println(claims);
    }
}

运行后输出如下:

header={typ=JWT, alg=HS256},body={sub=JWT 介绍, name=Huo Haodong, iat=1672651616, jti=10086, opr=read},signature=Wza3GFuiJLP0NO97Gt0_kpMzIA_vWlUnn5_FR8s6ORQ

客户端获取到 JWT 后,应该存储在哪里?

不同场景下有不同的解决方案,对于浏览器而言,JWT 可以存储在任何可以保存数据地方9

  • Cookie:将 JWT 放入 Cookie 中,可以保证客户端每次发送的 HTTP 请求中都自动携带 JWT,实现起来比较简单。设置 HttpOnlySameSite=strict 等安全选项后,还可以避免 CSRFXSS 攻击。缺点是 Cookie 方案只适用于浏览器客户端中,泛用性不强。此外,如果想要实现 OAuth2 等安全标准,还需要额外将 Cookie 中的 JWT 构造到 HTTP 请求的 Authorization 字段中。
  • Session Storage 和 Local Storage:将 JWT 放入 Session Storage 和 Local Storage 中一方面需要按照某种方式(OAuth2 标准,或自定义标准)将 JWT 嵌入到 HTTP 请求中,另一方面还需要采取一些安全措施来避免 JWT 泄露,总体来说不如基于 Cookie 的方案安全简单,因此 Session Storage 和 Local Storage 的方案并不常用。

    如果客户端是非浏览器,则需要考虑使用对应客户端平台的原生存储机制,或者相应的开发库来管理 JWT。

JWT 应该放到 HTTP 请求报文的哪个部分?

目前主流方案都依据 OAuth2 认证标准来实现 JWT 认证,即将 JWT 嵌入到 HTTP 请求头的 Authorization 字段中:Authorization: Bearer <JWT>。其中 Bearer 表示采用 Token 的形式进行认证,是 HTTP 标准的一部分。客户端只需要将 JWT 放入 Bearer 之后即可完成 HTTP 请求的构造。下面是一个简单的例子:

GET /some_resource.html HTTP/1.1
Host: huohaodong.com
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJKIn0.3XXHF06DpaQOKPIuHiwv4qseFeRKMeJeObSO1Aegua8

客户端如何做到每次发起 HTTP 请求时都附加 JWT?

根据 OAuth2 标准,我们只需要将客户端每次发出的 HTTP 请求中嵌入附带 JWT 的 Authorization 字段即可。不同的 HTTP 库有不同的实现方式,这里以 JavaScript 的 Axios 库为例,我们只需要利用 Axios 的 Interceptor 机制即可:

import axios from 'axios'

const http = axios.create({
  baseURL: 'http://example.jwt.huohaodong.com',
  timeout: 2000,
})

http.interceptors.request.use(
  (config) => {
    const token = getToken() // 通过某种方式获取到客户端本地存储的 JWT
    if (token) {
      config.headers.Authorization = `Bearer ${token}` // 在 HTTP 请求头中嵌入 JWT
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

JWT 和 Session

JWT 作为一种将状态存储在客户端的方案,同样可以实现 Session 机制。与通过 Cookie 实现的 Session 方案相比,JWT 具有如下优缺点:

  1. 服务端不需要维护状态信息,只需要在收到 JWT 后按照相应规则校验 JWT 是否合法即可,而基于 Cookie 的方案则需要服务端维护对应的 Session 信息(比如 JSESSION_ID),在 Session 数量较多的情况下服务器负担较重(内存,数据库请求等);

  2. 由于在服务端存储会话信息,基于 Cookie 方案难以实现水平扩容:在用户请求量增大时,由于单个服务器的性能有限,此时需要增加新的服务器资源,旧客户端的请求可能会被负载均衡器分配到新服务器上,此时有两种情况:

  • 假如服务端将会话信息维护在内存中,那么新服务器的内存中此时并没有客户端之前的会话信息,客户端之前建立的所有 Session 信息都无法获取到,需要重新建立 Session,进而导致一个客户端与多个服务器同时建立了不同的 Session。服务端为了保证应用程序的逻辑正确性,可能还需要额外维护会话信息的一致性,实现难度较大,是一种技术上比较无解的场景;
  • 假如服务端将会话信息维护在数据库中(Redis、MySQL、PostgreSQL 等,这里需要注意 Redis 作为内存数据库与上面提到的维护在服务器本地内存中的方案是不同的),服务端则可能需要维护每个 Session 对应的关键数据,如果数据库本身还进行了水平扩容,则还需要维护分布式数据库的一致性,同样增大了服务端的维护成本;
  • 对于 JWT 而言,由于服务端不需要主动维护任何状态信息,因此在服务端发生的水平扩容对于客户端而言是无感的,在一定程度上可以避免上述问题。
  1. 基于 Cookie 的方案不方便实现跨域与跨端(浏览器、手机、或者纯粹的 HTTP Client),且只适用于客户端是浏览器的场景(因为 Cookie 只存在于浏览器中)。而 JWT 只需要客户端将本地存储的 Token 信息嵌入到每个 HTTP 请求头中即可实现跨域和跨端;

  2. 由于 JWT 在签发后存储在客户端,服务端难以主动将 JWT 作过期处理。一种可行的方案是服务端将主动过期的 Token 存储在数据库中(比如 Redis),并建立一个 Token 过期列表来存储主动失效的 Token。服务端每次在收到 JWT 后只需要额外向数据库检查当前 Token 是否已被服务端主动过期处理即可。此时 JWT 已经和基于 Cookie 的方案非常相似了,因为同样需要在服务端引入状态信息(主动做过期处理的 Token 列表),但是服务端主动将 Token 过期的情况很少,一般情况下数据库中只需要存储少量过期的 Token,对于服务器性能的影响较小。

JWT 的安全性

JWT 的 Header 和 Payload 中的信息在默认情况下是没有加密的,JWT 只是采用了 Base64URL 编码进行传输,因此不适合在 JWT 的 Payload 部分携带敏感信息。

JWT 主要作为 HTTP 请求头中的一部分进行传输,其本身是安全的。在我看来,JWT 技术的所谓不安全性是由使用者引入的:

  • 对于开发者而言:为什么非要在一个主要作为 Token 使用的技术中引入敏感数据?JWT 本身是嵌入在 HTTP 请求中的,HTTP 本身已经提供了完善的、通用的安全解决方案:HTTPS。既然是敏感数据,放入 HTTPS 请求的 Body 中能够解决 99.99% 的安全传输问题。如果业务需要,必须要将敏感信息塞到 JWT 里面,则可以考虑对 JWT 的 Payload 部分进行加密处理:客户端和服务端采用非对称加密,客户端使用服务端提供的公钥对 Payload 中的敏感数据进行加密,服务端使用私钥对 Payload 进行解密,通过这种方式保证存储在客户端本地的 JWT 是安全的,以此解决 JWT 的安全存储问题;
  • 对于用户而言:由于 JWT 签发后存储在用户本地,用户的一些不安全的使用习惯导致的 Token 泄露同样引入了安全问题,但这超出了服务端的能力范围:服务端本身只会对 JWT 做校验,一般情况下并不会对 JWT 做进一步的安全性检查(如果需要的话可以在这里嵌入一层安全校验,比如 QQ 的异地登录检测)。技术上只能防范而无法完全避免社会工程学攻击远有 Uber 的用户数据,近有 Rockstar 的 GTA6,技术上再强,规模上再大的公司也难以幸免,在我看来,这已经远远超过了技术讨论的范畴。

总而言之,想要保证 JWT 的安全性非常简单,在没有对 Payload 进行加密的情况下,不要在 JWT 的 Payload 中保存敏感信息,这对于任何保存在用户本地的敏感数据而言都是适用的。

Footnotes

  1. CERN httpd

  2. HTTP 协议响应头中 Set-Cookie 的具体语法

  3. MDN 文档对 Cookie 的具体规范和细节进行了详尽的介绍。

  4. 主流浏览器对 Cookie 进行的限制

  5. Stack Overflow 上对 Cookie 大小限制的讨论

  6. Chrome 95 版本将 Cookie 限制与 Firefox 同步以提高代码兼容性

  7. Web Storage API

  8. JWT 规范中 Payload 部分的默认字段

  9. JWT 可以存储在浏览器提供的各类存储方案中