这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记。
1. 什么是JWT
JWT,全名JSON Web Token,由RFC 7519标准定义,主要设计目的是用一种紧凑而自包含的方式在各方之间安全的传输消息.JWT可以通过签名实现身份认证,同时也能提供对消息的完整性保护(防篡改).
2. JWT常见使用场景
-
授权与认证: 目前广泛使用JWT来实现单点登录.当客户端的用户第一次登录时,需要提供账号和密码,如果通过了服务器端对账号和密码的校验后,服务端将会给用户发送一个有一定使用期限的令牌(即token),里面包含了用户的相关信息以及允许的路由/服务/资源.在用户后续的请求中,只需要包含这个token即可.
-
信息交换: 就是在在各方之间安全传输信息,这个用的比较少.
3. JWT的组成结构
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NTM1NjYwNjcsImlkIjoxLCJvcmlnX2lhdCI6MTY1MzU2MjQ2N30.tbqK6lKg9oCTxkYXnNjK8pu2FxArSbHD7Kfx1nerOyc
上面给出了一个token的例子.JWT由两个点号(.)隔开,分为三个部分:
-
Header: 头部,或者叫标题;
-
Payload: 有效载荷
-
Signature: 签名
3.1 Header
头部通常由两部分组成:
-
令牌的类型,即 JWT
-
使用的签名算法,例如 HMAC SHA256 或 RSA
他们会组成一个JSON对象,然后用base64UrlEncode算法对该JSON对象进行转换.这样就得到了JWT的第一部分.
对例子中的第一个部分解码,得到:
{
"alg": "HS256",
"typ": "JWT"
}
说明采用的是HMAC SHA256签名算法.
3.2 Payload
有效载荷也是一个json对象,放的就是通过JWT实际传输的信息.常见的字段是:
-
aud: 接收方
-
iat: 该JWT的签发时间
-
exp: 该JWT的过期时间
-
自定义字段
同样,用base64UrlEncode算法对该JSON对象进行转换.这样就得到了JWT的第二部分.
需要注意的是,base64UrlEncode算法只是一种编码算法而不是加密算法,任何人都可以解码.因此,Payload部分不要放安全相关的信息.如果一定要,建议加密后再放入.
对例子中的第二个部分解码,得到:
{
"exp": 1653566067,
"id": 1,
"orig_iat": 1653562467
}
可以看到JWT的过期时间,签发时间,以及用户信息(这里是用户在服务端的数据库表中的id).
3.3 Signature
签名是对Header和Payload进行约定的算法和规则的签名.签名的目的是保护消息完整性以及用户认证.JWT可以使用对称密钥(如使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名。
对于我们的例子,使用HMAC SHA256签名算法,其签名的结构如下:
HMACSHA256(
base64UrlEncode(header) + "."
+ base64UrlEncode(payload),
secret)
其中secert是一个只有客户端和服务器知道的对称密钥.因此,攻击者虽然可以解开Header和Payload,却无法篡改其内容,否则签名就对不上了.
4. 笔记服务中JWT的使用
以笔记服务(即easy_note)为例,他使用了gin-jwt框架,从而增加gin框架对JWT的支持.我们看其处理路由的main函数:
func main() {
Init()
r := gin.New()
authMiddleware, _ := jwt.New(&jwt.GinJWTMiddleware{
Key: []byte(constants.SecretKey),
Timeout: time.Hour,
MaxRefresh: time.Hour,
PayloadFunc: func(data interface{}) jwt.MapClaims {
if v, ok := data.(int64); ok {
return jwt.MapClaims{
constants.IdentityKey: v,
}
}
return jwt.MapClaims{}
},
Authenticator: func(c *gin.Context) (interface{}, error) {
var loginVar handlers.UserParam
if err := c.ShouldBind(&loginVar); err != nil {
return "", jwt.ErrMissingLoginValues
}
if len(loginVar.UserName) == 0 || len(loginVar.PassWord) == 0 {
return "", jwt.ErrMissingLoginValues
}
return rpc.CheckUser(context.Background(), &userdemo.CheckUserRequest{UserName: loginVar.UserName, Password: loginVar.PassWord})
},
TokenLookup: "header: Authorization, query: token, cookie: jwt",
TokenHeadName: "Bearer",
TimeFunc: time.Now,
})
v1 := r.Group("/v1")
user1 := v1.Group("/user")
user1.POST("/login", authMiddleware.LoginHandler)
user1.POST("/register", handlers.Register)
note1 := v1.Group("/note")
note1.Use(authMiddleware.MiddlewareFunc())
note1.GET("/query", handlers.QueryNote)
note1.POST("", handlers.CreateNote)
note1.PUT("/:note_id", handlers.UpdateNote)
note1.DELETE("/:note_id", handlers.DeleteNote)
if err := http.ListenAndServe(":8080", r); err != nil {
klog.Fatal(err)
}
}
我们在使用时,需要NEW一个中间件,并写入我们想要的处理逻辑,解释如下:
-
key: 如果使用的是HMAC SHA256算法,这里就是secret,注意要转成byte;
-
Timeout和MaxRefresh: jwt过期时间和刷新token的时间,这里都是一个小时;
-
PayloadFunc: 通过重写该函数,可以在签发JWT令牌时放入我们想放入的信息.这里就是把用户在数据库中的id放了进去.
-
Authenticator: 通过重写该函数,实现用户登录时的认证逻辑.在这里,我们就是去数据库查看一下用户登录请求中发送的用户名和密码是否正确,通过了该认证函数之后,gin-jwt才会进行后续的签发令牌等动作.
-
TokenLookup: 定义用户请求中的哪些地方可能会存放token,框架会从这些地方尝试解析token.这里是header,query参数以及cokie字段.
-
TokenHeadName:如果token放在请求的header中,那么就需要在这里指明header的名字.这里是"Bearer"
-
TimeFunc: 提供当前时间,可以重写的目的主要是为了考虑处理不同时区的用户.这里就不考虑了,简单的使用time.Now().
对于某一个路由组,如果Use(authMiddleware.MiddlewareFunc())之后,那么该路由组在Use JWT中间件之前的所有路由不开启认证,在之后的路由开启jwt认证.在easy_note中, note1的路由就全部开启了认证.
jwt认证的工作流程如下:
- 如果开启了路由,那么当gin接收到请求后,就会调用jwt.GinJWTMiddleware中间件,看看token有没有/是不是非法的/是不是过期的,然后解析token.如果中间出错了,就会直接返回;只有token解析成功,才能进入我们定义的该路由的handler函数.