概述

在实际应用中,很多API接口通常都需要在用户登录后才允许访问操作。但是显然不可能每次访问页面都携带用户名密码数据,这样不仅不安全,而且还会对数据库造成较大的压力。所以我们需要另辟蹊径。

实际上,由于http请求是无状态的,所以为了维持登录状态,必须在每次请求时,携带某种能够代表用户信息的字符串,且该字符串唯一,不可被伪造。这个字符串通常称为token。

想象以下,有一个地方,必须需要拿出相关证明才能入内。而该证明需要你通过某些个人信息去办理才能申请的到。为了防止开假证明,通常需要对证明进行签字,按压指纹等操作。

jwt实际上可以采用上述场景做比喻。当你需要访问服务器API时,服务器要求你携带jwt字符串才能访问该api,而jwt字符串需要你先使用用户名密码登录至系统才能申请到。为了防止jwt字符串被伪造,通常还需要使用服务器上存放的密钥来参与最终jwt字符串的构成。

由于计算机中字符串可轻易篡改,故不能将服务器上的密钥直接组合到jwt字符串中,而是需要服务器端存储的密钥数据与半成品jwt字符串做某种加密运算,得到加密后的签名,拼接在半成品jwt字符串之后,中间用.隔开。即实现jwt字符串的生成。之后服务器拿到该字符串只需要验证前半部分半成品jwt经过加密后是否与后面的签名一致即可发现该jwt字符串是否发生篡改。

jwt登录流程

如下图的顺序图即一次正常登录与请求的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@startuml
actor 用户 as user
participant 浏览器 as browser
participant 服务器 as server
database 数据库 as db
user -> browser++: 输入用户名,点击登录

browser -> server++: POST /login \n {username,password}
server -> db++: 验证username与password是否对应
return 返回验证结果
server -> server: 创建jwt字符串
return jwt字符串

user -> browser: 进行需要登录的操作
browser -> server++: 发送请求且header携带jwt字符串
server -> server: 检查jwt是否合法,解析用户uid
server -> server: 进行业务逻辑处理
return 返回响应
@enduml

jwt结构

jwt全程为 JSON Web Token,是现行的一种开放标准,不限定具体的编程语言。

JWT由三部分构成:header(头部),payload(载荷)或claim,signature(签名)。

header头部

这部分主要有两个字段

  • alg:加密算法的类型

  • typ:token的类型,这里就是jwt

payload载荷

这部分定义了七个标准字段

  • iss :token的发行者
  • sub :token面向的用户
  • aud :受众
  • exp :token的过期时间,Unix时间戳
  • nbf :not before , 如果当前时间在 nbf 里的时间之前,则Token不被接受
  • iat :token的签发时间,Unix时间戳
  • jti :当前token的唯一标识

当然我们也可以额外添加一些信息进入,减少服务器查询数据库的压力。

signature签名

由header和payload的json字符串的base64编码中间用.隔开的字符串拼接后经过header.alg算法加密而成

jwt字符串构成

最终的jwt字符串由三部分拼接而成:header的json字符串的base64编码,payload的json字符串的base64编码,signature,中间用.作为分割符拼接而成的总字符串。

生成后的字符串通常为如下形式

代码生成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
package main

import (
"crypto"
"crypto/hmac"
_ "crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"time"
)

type H map[string]interface{}

func main() {
secretKey := "abc"
headerJson, _ := json.Marshal(H{
"alg": "HS256", // 一种加密算法
"typ": "JWT",
})

payloadJson, _ := json.Marshal(H{
"exp": time.Now().Add(time.Hour).Unix(), // 过期时间
"uid": 12,
})

headerBase64 := base64.StdEncoding.EncodeToString(headerJson)
payloadBase64 := base64.StdEncoding.EncodeToString(payloadJson)
signatureRaw := fmt.Sprintf("%s.%s", headerBase64, payloadBase64)
m := crypto.SHA256
if !m.Available() {
// 一但执行log.Fatal程序就直接打印完这一句后退出了
log.Fatalln("SHA256不可用")
}
hasher := hmac.New(m.New, []byte(secretKey))
hasher.Write([]byte(signatureRaw))
signature := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))

token := fmt.Sprintf("%s.%s", signatureRaw, signature)
log.Println(token)
}

使用上述代码,可生成如下jwt字符串

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NTQ4MzI1NTYsInVpZCI6MTJ9.cPXYeqyUYOHGJ8uXPzA82MqU5iPFLVxFq2YhAvyHm_E

验证代码的正确性

打开网址JSON Web Tokens - jwt.io,可进行在线jwt生成与解析

如上图,我们将jwt的前半部分粘贴进入,再将密钥abc填入输入框中,得到下图

可发现golang生成的jwt字符串与浏览器上生成的相一致,说明jwt生成的代码的正确性,jwt.io网站也是一个常用的jwt解析工具。

工程使用

实际开发时,一般我们会调用现成的jwt库,如golang中常用的

github.com/golang-jwt/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
package main

import (
"crypto"
"crypto/hmac"
_ "crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/golang-jwt/jwt"
"log"
"time"
)

type H map[string]interface{}

func myJwt(secretKey string, uid int, exp int64) string {
headerJson, _ := json.Marshal(H{
"alg": "HS256", // 一种加密算法
"typ": "JWT",
})

payloadJson, _ := json.Marshal(H{
"exp": exp, // 过期时间
"uid": 12,
})

headerBase64 := base64.StdEncoding.EncodeToString(headerJson)
payloadBase64 := base64.StdEncoding.EncodeToString(payloadJson)
signatureRaw := fmt.Sprintf("%s.%s", headerBase64, payloadBase64)
m := crypto.SHA256
if !m.Available() {
// 一但执行log.Fatal程序就直接打印完这一句后退出了
log.Fatalln("SHA256不可用")
}
hasher := hmac.New(m.New, []byte(secretKey))
hasher.Write([]byte(signatureRaw))
signature := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))

token := fmt.Sprintf("%s.%s", signatureRaw, signature)
return token
}

func libJwt(secretKey string, uid int, exp int64) string {
t, _ := jwt.NewWithClaims(
jwt.SigningMethodHS256,
struct {
jwt.StandardClaims
Uid int `json:"uid"`
}{
jwt.StandardClaims{
ExpiresAt: exp,
},
uid,
},
).SignedString([]byte(secretKey))
return t
}

func main() {
uid := 12 // 用户id
exp := time.Now().Add(time.Hour).Unix() // 过期时间
secretKey := "abc" // 密钥
log.Println(myJwt(secretKey, uid, exp))
log.Println(libJwt(secretKey, uid, exp))
}

上述代码定义了两个函数,一个是我们自己写的jwt生成函数,一个是调用第三方库的生成函数。

输出结果如下:

2022/06/10 14:06:58 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NTQ4NDQ4MTg
sInVpZCI6MTJ9._QeTrywcIRbEa_Ii1-i3JEyRNc2XQw1GSB_E2EKv5iU
2022/06/10 14:06:58 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NTQ4NDQ4MTg
sInVpZCI6MTJ9._QeTrywcIRbEa_Ii1-i3JEyRNc2XQw1GSB_E2EKv5iU

可发现两个生成结果一样,

实际使用时,我们的claims经常需要定制一些字段,故我们经常需要对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
package util

import (
"github.com/golang-jwt/jwt"
"time"
)

type Jwt struct {
secretKey string
expiresDuration time.Duration
}

func NewJwt(secretKey string, expiresDuration time.Duration) *Jwt {
return &Jwt{
secretKey: secretKey,
expiresDuration: expiresDuration,
}
}

type customClaims struct {
jwt.StandardClaims
Uid int `json:"uid"`
}

func (j *Jwt) GenerateToken(uid int) string {
t := jwt.NewWithClaims(
jwt.SigningMethodHS256,
customClaims{
jwt.StandardClaims{
ExpiresAt: time.Now().Add(j.expiresDuration).Unix(),
},
uid,
},
)
token, _ := t.SignedString([]byte(j.secretKey))
return token
}

func (j *Jwt) ParseToken(token string) (*customClaims, error) {
var cc customClaims
_, err := jwt.ParseWithClaims(token, &cc, func(t *jwt.Token) (interface{}, error) {
return []byte(j.secretKey), nil
})
if err != nil {
return &cc, err
}
return &cc, nil
}