0%

JWT 基础

这篇文章将介绍 JWT(JSON Web Token)的基础知识,并通过一个实际例子加深对 JWT 的理解。

什么是 JWT

JSON Web 令牌(JSON Web Token)是一种开放标准(RFC7519),它定义了一种紧凑且自包含的方式,用于在各方之间以 JSON 对象的形式安全地传输信息。此信息是经过数字签名的,因此可以验证和信任。可以使用密钥(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥/私钥对对 JWT 进行签名。

尽管 JWT 可以加密以在各方之间提供机密性,但重点还是 签名令牌。签名令牌可以验证其中 包含的声明 的完整性,而加密令牌则对他人隐藏这些声明。当使用一对公钥/私钥来对令牌进行签名时,还能证明签名的确来自于 持有私钥的一方

JWT 的使用场景

以下是 JWT 有用的一些场景:

  • 认证:这是使用 JWT 的最常见场景。用户登录后,每个后续请求都将包含 JWT,允许用户访问该令牌所允许的路由、服务和资源。单点登录是一种广泛使用了 JWT 的功能,因为它的开销小,并且能够轻松地跨不同域使用

  • 信息交换:JWT 是在各方之间安全地传输信息的好方法。由于 JWT 可以被签名(例如使用公钥/私钥对),因此可以确保发送者的确是他们所声称的身份。此外,由于签名是基于 header 和 payload 计算的,因此还可以验证内容是否未被篡改

JWT 结构

在其紧凑形式中,JWT 由三个部分组成,由点 . 分隔,它们是:

  • Header
  • Payload
  • Signature

因此,JWT 通常如下所示。

1
xxxxx.yyyyy.zzzzz

Header(头部)

Header 通常由两部分组成:令牌的类型(JWT)和正在使用的签名算法,例如 HMAC SHA256 或 RSA。

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

然后,该 JSON 经过 Base64Url 编码以构成 JWT 的第一部分。

Payload(有效载荷)

JWT 的第二部分是 payload(有效载荷),它包含了 声明(claims),声明 可以认为是关于实体(通常是用户)及其额外数据的说明。有 3 种 声明已注册声明(registered)、公共声明(public)、私有声明(private)。

  • 已注册声明:一组预定义的声明,不是强制性的,但建议使用,以提供一组有用的、可互操作的声明。其中一些是:iss(颁发者)、exp(过期时间)、sub(主题)、aud(受众 )
  • 公共声明:这些声明可以由使用 JWT 的用户随意定义。但为避免冲突,应在 IANA JSON Web 令牌注册表 中定义它们,或将其定义为包含 抗冲突命名空间 的 URI
  • 私有声明:这些是自定义声明,用于在同意使用它们的各方之间共享信息,既不是注册声明,也不是公开声明

例如一个有效的负载可以是

1
2
3
4
5
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

然后对 Payload 进行 Base64URL 编码,以形成 JWT 的第二部分。

需要注意,对于签名令牌,此信息虽然可以防止篡改,但任何人都可以读取。除非 JWT 已加密,否则不要将机密信息放在 JWT 的 payload 或 header 元素中

Signature(签名)

要创建签名部分,需要使用 已编码的 header已编码的 payload秘钥,并按照 header 中所指定的算法,对它进行签名。例如如果使用 HMAC SHA256 算法,将按照如下方式计算签名:

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)

签名用于验证消息在整个过程中没有被更改,并且在使用私钥签名的令牌的情况下,它还可以验证 JWT 的发送者是否是它所声称的身份

汇总

最终得到的 JWT 是由 . 分隔的三个 BASE64URL 编码的字符串,可以在 HTML 和 HTTP 环境中轻松传递。如下显示了一个 JWT:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.he0ErCNloe4J7Id0Ry2SEDg09lKkZkfsRiGsdX_vgEg

可以在 jwt.io Debugger 中解码、验证和生成 JWT。

JWT 如何工作

在身份验证中,当用户使用其凭证成功登录时,将返回 JSON Web 令牌。由于令牌是凭据,因此必须非常小心以防止出现安全问题。通常,令牌的保留时间不应超过所需时间。由于缺乏安全性,也不应将敏感会话数据存储在浏览器存储中。

每当用户想要访问受保护的路由或资源时,用户都应该发送 JWT,通常保存在 Authorization 头部(使用 Bearer 验证类型)。

1
Authorization: Bearer <token>

在某些情况下,它是一种在无状态授权机制。服务器在 Authorization 头部中检查是否存在有效的 JWT,如果存在,则允许用户访问受保护的资源。如果 JWT 包含必要的数据,那么服务器上某些操作所需要的数据库查询也可以减少。

需要注意,如果在 HTTP header 中发送 JWT 令牌,需要避免 JWT 太大,因为某些服务器可能不接受超过 8KB 的标头。如果需要在 JWT 令牌中嵌入太多信息,例如包含所有用户的权限,那么可能需要替代方案,例如 Auth0 Fine-Grained Authorization

如果令牌是在 Authorization header 中发送,则跨域资源共享(CORS)不会有问题,因为它不是使用 Cookie。

如下展示了如何获取 JWT 并访问 API 或资源:

  • 应用程序或客户端向授权服务器请求授权,这是通过一条独立的认证流来实现的。例如,典型的符合 OpenID Connect 的 Web 应用程序会使用 授权代码流
  • 授予授权后,授权服务器将向应用程序返回访问令牌
  • 应用程序使用访问令牌访问受保护的资源(如 API)

请注意,使用签名令牌时,令牌中包含的所有信息都会公开给用户(虽然他们无法更改它)。这意味着不应将机密信息放在令牌中

为什么使用 JWT 令牌

相比于 简单 Web 令牌(Simple Web Tokens,SWT)和 安全断言标记语言令牌(Security Assertion Markup Language Tokens,SAML),JWT 具有以下优点:

  • 由于 JSON 不如 XML 详细,因此在编码时,其大小也更小。因此 JWT 相比于 SAML 更加紧凑,这使得 JWT 成为在 HTML 和 HTTP 环境中传递的好选择
  • 在安全性方面,SWT 只能由使用 HMAC 算法的共享密钥进行签名,而 JWT 和 SAML 令牌可以使用 X.509 证书形式的公钥/私钥对进行签名。相比于 JSON 签名的简单性,对 XML 进行签名而不引入安全漏洞是比较困难的一件事
  • JSON 的解析器在多数编程语言中很常见,这使得使用 JWT 更加容易(相比于 SAML)

JWT 和 OAuth 2.0

需要注意,JWT 并不等价于 OAuth。JWT 令牌只是一个签名的 JSON 对象。尽管 JWT 是 OAuth2 身份验证中最常用的 bearer token,但其实 JWT 可以用于任何可用的地方。简单来说:

  • OAuth 是一种协议,允许身份提供商与用户登录的服务分开。例如,每当使用 Facebook 登录其他服务(Yelp、Spotify 等)时,你都在使用 OAuth
  • OAuth 定义了几种用于传递身份验证数据的选项,其中最流行的方法就是 bearer tokenbearer token 只是一个字符串,只有经过身份验证的用户才能持有,因此携带该 token 就能证明你的身份。而 JWT 就是一个好的 bearer token
  • 由于 bearer token 用于身份验证,因此必须对其进行保密。这也是为什么要在 TLS 通信中才使用 bearer token

JWT 示例

最后我们来看一个 JWT 的实例,以加深对 JWT 的理解。这个例子来自于 golang-jwt 的一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
package main

import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"regexp"
"sort"
"strings"

"github.com/golang-jwt/jwt/v5"
)

var (
// flag 的定义
// Options
flagAlg = flag.String("alg", "", algHelp())
flagKey = flag.String("key", "", "path to key file or '-' to read from stdin")
flagCompact = flag.Bool("compact", false, "output compact JSON")
flagDebug = flag.Bool("debug", false, "print out all kinds of debug data")
// ArgList 是自定义类型,表示键值对的 map
// 可以用来处理命令行参数中的 header 和 claims
flagClaims = make(ArgList)
flagHead = make(ArgList)

// Modes - exactly one of these is required
flagSign = flag.String("sign", "", "path to claims file to sign, '-' to read from stdin, or '+' to use only -claim args")
flagVerify = flag.String("verify", "", "path to JWT token file to verify or '-' to read from stdin")
flagShow = flag.String("show", "", "path to JWT token file to show without verification or '-' to read from stdin")
)

// 示例程序的 main 函数
func main() {
// Plug in Var flags
flag.Var(flagClaims, "claim", "add additional claims. may be used more than once")
flag.Var(flagHead, "header", "add additional header params. may be used more than once")

// Usage message if you ask for -help or if you mess up inputs.
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
fmt.Fprintf(os.Stderr, " One of the following flags is required: sign, verify or show\n")
flag.PrintDefaults()
}

// 解析命令行参数
// Parse command line options
flag.Parse()

// Do the thing. If something goes wrong, print error to stderr
// and exit with a non-zero status code
if err := start(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

// 程序的核心逻辑,根据命令行参数决定执行的操作
// Figure out which thing to do and then do that
func start() error {
switch {
// 读取 claims 数据,创建签名后的 JWT token
case *flagSign != "":
return signToken()
case *flagVerify != "":
return verifyToken()
// 展示 token
case *flagShow != "":
return showToken()
default:
flag.Usage()
return fmt.Errorf("none of the required flags are present. What do you want me to do?")
}
}

// 从指定的文件路径中读取文件,如果传入的路径为 `-`,则从标准输入中读取
// 如果传入的路径为 `+`,则返回一个空的 JSON 对象字符串
// Helper func: Read input from specified file or stdin
func loadData(p string) ([]byte, error) {
if p == "" {
return nil, fmt.Errorf("no path specified")
}

var rdr io.Reader
switch p {
case "-":
rdr = os.Stdin
case "+":
return []byte("{}"), nil
default:
f, err := os.Open(p)
if err != nil {
return nil, err
}
rdr = f
defer f.Close()
}
return io.ReadAll(rdr)
}

// Print a json object in accordance with the prophecy (or the command line options)
func printJSON(j interface{}) error {
var out []byte
var err error

if !*flagCompact {
out, err = json.MarshalIndent(j, "", " ")
} else {
out, err = json.Marshal(j)
}

if err == nil {
fmt.Println(string(out))
}

return err
}

// 读取 token 并进行校验,输出 claims 数据
// Verify a token and output the claims. This is a great example
// of how to verify and view a token.
func verifyToken() error {
// 读取 token 数据
// get the token
tokData, err := loadData(*flagVerify)
if err != nil {
return fmt.Errorf("couldn't read token: %w", err)
}

// trim possible whitespace from token
tokData = regexp.MustCompile(`\s*$`).ReplaceAll(tokData, []byte{})
if *flagDebug {
fmt.Fprintf(os.Stderr, "Token len: %v bytes\n", len(tokData))
}

// 解析并验证 token
// 接收一个 KeyFunc 函数用于获取签名密钥
// 之后使用该秘钥校验 token 中的签名
// Parse the token. Load the key from command line option
token, err := jwt.Parse(string(tokData), func(t *jwt.Token) (interface{}, error) {
if isNone() {
return jwt.UnsafeAllowNoneSignatureType, nil
}
data, err := loadData(*flagKey)
if err != nil {
return nil, err
}
switch {
case isEs():
return jwt.ParseECPublicKeyFromPEM(data)
case isRs():
return jwt.ParseRSAPublicKeyFromPEM(data)
case isEd():
return jwt.ParseEdPublicKeyFromPEM(data)
default:
return data, nil
}
})

// Print an error if we can't parse for some reason
if err != nil {
return fmt.Errorf("couldn't parse token: %w", err)
}

// Print some debug data
if *flagDebug {
fmt.Fprintf(os.Stderr, "Header:\n%v\n", token.Header)
fmt.Fprintf(os.Stderr, "Claims:\n%v\n", token.Claims)
}

// 输出 claims 数据
// Print the token details
if err := printJSON(token.Claims); err != nil {
return fmt.Errorf("failed to output claims: %w", err)
}

return nil
}

// 读取 claims 数据,输出签名后的 JWT token
// Create, sign, and output a token. This is a great, simple example of
// how to use this library to create and sign a token.
func signToken() error {
// 根据 flagSign 读取要签名的 claims 数据,它是一个 json 字符串
// get the token data from command line arguments
tokData, err := loadData(*flagSign)
if err != nil {
return fmt.Errorf("couldn't read token: %w", err)
} else if *flagDebug {
fmt.Fprintf(os.Stderr, "Token: %v bytes", len(tokData))
}

// 将 claims 数据反序列化为对应的 MapClaims 结构体
// parse the JSON of the claims
var claims jwt.MapClaims
if err := json.Unmarshal(tokData, &claims); err != nil {
return fmt.Errorf("couldn't parse claims JSON: %w", err)
}

// 额外要添加的 claims 数据
// add command line claims
if len(flagClaims) > 0 {
// 直接添加到 claims 中
for k, v := range flagClaims {
claims[k] = v
}
}

// 读取 key 数据
// get the key
var key interface{}
if isNone() {
key = jwt.UnsafeAllowNoneSignatureType
} else {
key, err = loadData(*flagKey)
if err != nil {
return fmt.Errorf("couldn't read key: %w", err)
}
}

// 获取签名方法
// get the signing alg
alg := jwt.GetSigningMethod(*flagAlg)
if alg == nil {
return fmt.Errorf("couldn't find signing method: %v", *flagAlg)
}

// 基于签名方法、claims 数据创建 token 对象
// create a new token
token := jwt.NewWithClaims(alg, claims)

// 额外添加 header 数据
// add command line headers
if len(flagHead) > 0 {
for k, v := range flagHead {
token.Header[k] = v
}
}

// 根据签名算法,解析 key
switch {
case isEs():
k, ok := key.([]byte)
if !ok {
return fmt.Errorf("couldn't convert key data to key")
}
key, err = jwt.ParseECPrivateKeyFromPEM(k)
if err != nil {
return err
}
case isRs():
k, ok := key.([]byte)
if !ok {
return fmt.Errorf("couldn't convert key data to key")
}
key, err = jwt.ParseRSAPrivateKeyFromPEM(k)
if err != nil {
return err
}
case isEd():
k, ok := key.([]byte)
if !ok {
return fmt.Errorf("couldn't convert key data to key")
}
key, err = jwt.ParseEdPrivateKeyFromPEM(k)
if err != nil {
return err
}
}

// 使用 key 对 token 进行签名,并输出结果
out, err := token.SignedString(key)
if err != nil {
return fmt.Errorf("error signing token: %w", err)
}
fmt.Println(out)

return nil
}

// 展示 token,对 token 字符串进行解析,并以 JSON 格式展示其内容。
// 但是不会对 token 中的签名进行验证
// showToken pretty-prints the token on the command line.
func showToken() error {
// get the token
tokData, err := loadData(*flagShow)
if err != nil {
return fmt.Errorf("couldn't read token: %w", err)
}

// trim possible whitespace from token
tokData = regexp.MustCompile(`\s*$`).ReplaceAll(tokData, []byte{})
if *flagDebug {
fmt.Fprintf(os.Stderr, "Token len: %v bytes\n", len(tokData))
}

// 创建一个 jwt parser,并调用其 ParseUnverified 方法来解析该 token 字符串
token, _, err := jwt.NewParser().ParseUnverified(string(tokData), make(jwt.MapClaims))
if err != nil {
return fmt.Errorf("malformed token: %w", err)
}

// 输出 header
// Print the token details
fmt.Println("Header:")
if err := printJSON(token.Header); err != nil {
return fmt.Errorf("failed to output header: %w", err)
}

// 输出 claims
fmt.Println("Claims:")
if err := printJSON(token.Claims); err != nil {
return fmt.Errorf("failed to output claims: %w", err)
}

return nil
}

func isEs() bool {
return strings.HasPrefix(*flagAlg, "ES")
}

func isRs() bool {
return strings.HasPrefix(*flagAlg, "RS") || strings.HasPrefix(*flagAlg, "PS")
}

func isEd() bool {
return *flagAlg == "EdDSA"
}

func isNone() bool {
return *flagAlg == "none"
}

func algHelp() string {
algs := jwt.GetAlgorithms()
sort.Strings(algs)

var b strings.Builder
b.WriteString("signing algorithm identifier, one of\n")
for i, alg := range algs {
if i > 0 {
if i%7 == 0 {
b.WriteString(",\n")
} else {
b.WriteString(", ")
}
}
b.WriteString(alg)
}
return b.String()
}

// 自定义类型,表示键值对的列表
// 用于在命令行参数中保存 JWT 的 header 和 claims 类型
type ArgList map[string]string

func (l ArgList) String() string {
// 直接序列化为 JSON 字符串
data, _ := json.Marshal(l)
return string(data)
}

func (l ArgList) Set(arg string) error {
// 解析 key=value 格式的字符串,并将其存储在 ArgList 中
parts := strings.SplitN(arg, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid argument '%v'. Must use format 'key=value'. %v", arg, parts)
}
l[parts[0]] = parts[1]
return nil
}

如下展示该程序的用法:

1
2
# echo {\"foo\":\"bar\"} | ./jwt -key ../../test/sample_key -alg RS256 -sign -
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.cwRajgVQbtoF_uUSMkn3ObxkTgaGlp6RRMU-np_gmvL0iFelT8gEsyZuubtZ0Ds1DMzv6d3n2yD_FpojVNKmr20NVMjjtARZ3_TVcl27F8bp-090fthqVNrubwGrZ6uYIZDGnTACKQbsVgfAD05Z4qwWFGXa1M7tNyHcKPH2fofBClw6Hi4BCBQnja2x3GF4PdESN6J0K1FeixxlxKGpZ5Klz8ic5HkUWcVk8hiBaHgls4UmBLIJTveZDpjNkuEOFxYPm7PaYYGr2pQbJ18FhwuapRt0J3L6g4zlokJE2NlX1VJ7DXzsdBfoCthI6p5SYuyDNHLwwGoArzFTTU_yAQ

由于前两段只是 base64URL 编码的字符串,我们可以直接解码他们。或者直接使用该程序的 -show 选项来展示:

1
2
3
4
5
6
7
8
9
10
# echo {\"foo\":\"bar\"} | ./jwt -key ../../test/sample_key -alg RS256 -sign - | ./jwt -show -
Header:
{
"alg": "RS256",
"typ": "JWT"
}
Claims:
{
"foo": "bar"
}

使用 -veritfy 选项来验证签名:

1
2
3
4
#  echo {\"foo\":\"bar\"} | ./jwt -key ../../test/sample_key -alg RS256 -sign - | ./jwt -key ../../test/sample_key.pub -alg RS256 -verify -
{
"foo": "bar"
}

Reference