JWT学习

这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天

JWT介绍

JSON Web Token (or JWT)只是一个包含某种意义数据的JSON串。它最重要的特性就是,为了确认它是否有效,我们只需要看JWT本身的内容,而不需要借助于第三方服务或者在多个请求之间将其保存在内存中-这是因为它本身携带了信息验证码MAC(Message Authentication Code)。

一个JWT包含3个部分:头部Header,数据Payload,签名Signature。

JWT Payload

Payload只是一个普通的Javascript 对象。对于payload的内容,JWT是没有任何限制的,但必须注意的是,JWT是没有加密的。因此,任何放在token里面的信息,如果被截获了,对任何人别人是可读的。因此,我们不应该在Payload里面存放任何黑客可以利用的用户信息。

JWT Header

Payload的内容在接收者端是通过签名(Signature)来校验的。不过存在多种类型的签名,因此,接收者需要知道使用的是哪种类型的签名。

这种关于token本身的元数据信息存放在另外的Javascript对象里面,并随着Payload一起发送给客户。这个独立的对象就是一个JSON对象,叫JWT Header,它也是普通的Javascript对象,在这里面我们可以看到签名类型信息,比如RS256。

JWT signatures

JWT的最后一部分是签名,它也叫信息验证码MAC。签名只能由拥有Payload、Header和密钥的角色生成。

  1. 用户向认证服务器提交用户名和密码,认证服务器也可以和应用服务器部署在一起,但往往是独立的居多;
  2. 认证服务器校验用户名和密码组合,然后创建一个JWT token,token的Payload里面包含用户的身份信息,以及过期时间戳;
  3. 认证服务器使用密钥对Header和Payload进行签名,然后发送给客户浏览器;
  4. 浏览器获取到经过签名的JWT token,然后在之后的每个HTTP请求中附带着发送给应用服务器。经过签名的JWT就像一个临时的用户凭证,代替了用户名和密码组合,之后都是JWT token和应用服务器打交道了;
  5. 应用服务器检查JWT签名,确认Payload确实是由密钥拥有者签过名的;
  6. Payload身份信息代表了某个用户;
  7. 只有认证服务器拥有私钥,并且认证服务器只把token发给提供了正确密码的用户;
  8. 因此应用服务器可以认为这个token是由认证服务器颁发的也是安全的,因为该用户具有了正确的密码;
  9. 应用服务器继续完成HTTP请求,并认为这些请求确实属于这个用户;

这样的话,黑客假扮合法用户的办法要么是盗到了用户名和密码组合,要么盗到了认证服务器上的签名私钥。

签名是JWT的关键部分,签名使得无状态的服务器只需要通过查看HTTP请求中的JWT token就能保证HTTP请求是来自某个用户,而不需要每次请求时都发送密码。

JWT的目标:让服务器无状态

JWT真正的好处是让认证服务器和校验JWT token的应用服务器可以完全分开,而让服务器无状态化只是它的一个副作用罢了。这意味着应用服务器只需要最简单的认证逻辑-校验JWT!我们可以将整个应用集群的登录/注册委托给一个单独的认证服务器。这也意味着应用服务器更简单更安全,因为更多的认证功能集中部署在认证服务器,可以被跨应用使用。

优点

1.可扩展性好

应用程序分布式部署的情况下,session需要做多机数据共享,通常可以存在数据库或者redis里面。而jwt不需要。

2.无状态

不在服务端存储任何状态。RESTful API的原则之一是无状态,发出请求时,总会返回带有参数的响应,不会产生附加影响。用户的认证状态引入这种附加影响,这破坏了这一原则。另外jwt的载荷中可以存储一些常用信息,用于交换信息,有效地使用 JWT,可以降低服务器查询数据库的次数。

缺点

1.安全性

由于jwt的payload是使用base64编码的,并没有加密,因此jwt中不能存储敏感数据。而session的信息是存在服务端的,相对来说更安全。

2.性能

jwt太长。由于是无状态使用JWT,所有的数据都被放到JWT里,如果还要进行一些数据交换,那载荷会更大,经过编码之后导致jwt非常长,cookie的限制大小一般是4k,cookie很可能放不下,所以jwt一般放在local storage里面。并且用户在系统中的每一次http请求都会把jwt携带在Header里面,http请求的Header可能比Body还要大。而sessionId只是很短的一个字符串,因此使用jwt的http请求比使用session的开销大得多。

3.一次性

无状态是jwt的特点,但也导致了这个问题,jwt是一次性的。想修改里面的内容,就必须签发一个新的jwt。

(1)无法废弃:通过上面jwt的验证机制可以看出来,一旦签发一个jwt,在到期之前就会始终有效,无法中途废弃。例如你在payload中存储了一些信息,当信息需要更新时,则重新签发一个JWT,但是由于旧的JWT还没过期,拿着这个旧的JWT依旧可以登录,那登录后服务端从JWT中拿到的信息就是过时的。为了解决这个问题,我们就需要在服务端部署额外的逻辑,例如设置一个黑名单,一旦签发了新的jwt,那么旧的就加入黑名单(比如存到redis里面),避免被再次使用。

(2)续签:如果你使用jwt做会话管理,传统的cookie续签方案一般都是框架自带的,session有效期30分钟,30分钟内如果有访问,有效期被刷新至30分钟。一样的道理,要改变jwt的有效时间,就要签发新的jwt。最简单的一种方式是每次请求刷新jwt,即每个http请求都返回一个新的jwt。这个方法不仅暴力不优雅,而且每次请求都要做jwt的加密解密,会带来性能问题。另一种方法是在redis中单独为每个jwt设置过期时间,每次访问时刷新jwt的过期时间。

可以看出想要破解jwt一次性的特性,就需要在服务端存储jwt的状态。但是引入 redis 之后,就把无状态的jwt硬生生变成了有状态了,违背了jwt的初衷。而且这个方案和session都差不多了。

适用场景

  • 有效期短
  • 只希望被使用一次

比如,用户注册后发一封邮件让其激活账户,通常邮件中需要有一个链接,这个链接需要具备以下的特性:能够标识用户,该链接具有时效性(通常只允许几小时之内激活),不能被篡改以激活其他可能的账户,一次性的。这种场景就适合使用jwt。

而由于jwt具有一次性的特性。单点登录和会话管理非常不适合用jwt,如果在服务端部署额外的逻辑存储jwt的状态,那还不如使用session。基于session有很多成熟的框架可以开箱即用,但是用jwt还要自己实现逻辑。

使用Gin框架集成JWT

Gin框架中使用JWT进行接口验证

在Golang语言中,jwt-go库提供了一些jwt编码和验证的工具,因此我们很容易使用该库来实现token认证。

另外,我们也知道gin框架中支持用户自定义middleware,我们可以很好的将jwt相关的逻辑封装在middleware中,然后对具体的接口进行认证。

自定义中间件

在gin框架中,自定义中间件比较容易,只要返回一个gin.HandlerFunc即完成一个中间件定义。

接下来,我们先定义一个用于jwt认证的中间件.

 // 定义一个JWTAuth的中间件
 func JWTAuth() gin.HandlerFunc {
     return func(c *gin.Context) {
         // 通过http header中的token解析来认证
         token := c.Request.Header.Get("token")
         if token == "" {
             c.JSON(http.StatusOK, gin.H{
                 "status": -1,
                 "msg":    "请求未携带token,无权限访问",
                 "data":   nil,
             })
             c.Abort()
             return
         }
 
         log.Print("get token: ", token)
 
         // 初始化一个JWT对象实例,并根据结构体方法来解析token
         j := NewJWT()
         // 解析token中包含的相关信息(有效载荷)
         claims, err := j.ParserToken(token)
 
         if err != nil {
             // token过期
             if err == TokenExpired {
                 c.JSON(http.StatusOK, gin.H{
                     "status": -1,
                     "msg":    "token授权已过期,请重新申请授权",
                     "data":   nil,
                 })
                 c.Abort()
                 return
             }
             // 其他错误
             c.JSON(http.StatusOK, gin.H{
                 "status": -1,
                 "msg":    err.Error(),
                 "data":   nil,
             })
             c.Abort()
             return
         }
 
         // 将解析后的有效载荷claims重新写入gin.Context引用对象中
         c.Set("claims", claims)
 
     }
 }
定义jwt编码和解码逻辑

根据前面提到的jwt-token的组成部分,以及jwt-go中相关的定义,我们可以使用如下方法进行生成token.

 // 定义一个jwt对象
 type JWT struct {
     // 声明签名信息
     SigningKey []byte
 }
 
 // 初始化jwt对象
 func NewJWT() *JWT {
     return &JWT{
         []byte("bgbiao.top"),
     }
 }
 
 // 自定义有效载荷(这里采用自定义的Name和Email作为有效载荷的一部分)
 type CustomClaims struct {
     Name  string `json:"name"`
     Email string `json:"email"`
     // StandardClaims结构体实现了Claims接口(Valid()函数)
     jwt.StandardClaims
 }
 
 
 // 调用jwt-go库生成token
 // 指定编码的算法为jwt.SigningMethodHS256
 func (j *JWT) CreateToken(claims CustomClaims) (string, error) {
     // https://gowalker.org/github.com/dgrijalva/jwt-go#Token
     // 返回一个token的结构体指针
     token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
     return token.SignedString(j.SigningKey)
 }
 
 
 // token解码
 func (j *JWT) ParserToken(tokenString string) (*CustomClaims, error) {
     // https://gowalker.org/github.com/dgrijalva/jwt-go#ParseWithClaims
     // 输入用户自定义的Claims结构体对象,token,以及自定义函数来解析token字符串为jwt的Token结构体指针
     // Keyfunc是匿名函数类型: type Keyfunc func(*Token) (interface{}, error)
     // func ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) {}
     token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
         return j.SigningKey, nil
     })
 
     if err != nil {
         // https://gowalker.org/github.com/dgrijalva/jwt-go#ValidationError
         // jwt.ValidationError 是一个无效token的错误结构
         if ve, ok := err.(*jwt.ValidationError); ok {
             // ValidationErrorMalformed是一个uint常量,表示token不可用
             if ve.Errors&jwt.ValidationErrorMalformed != 0 {
                 return nil, fmt.Errorf("token不可用")
                 // ValidationErrorExpired表示Token过期
             } else if ve.Errors&jwt.ValidationErrorExpired != 0 {
                 return nil, fmt.Errorf("token过期")
                 // ValidationErrorNotValidYet表示无效token
             } else if ve.Errors&jwt.ValidationErrorNotValidYet != 0 {
                 return nil, fmt.Errorf("无效的token")
             } else {
                 return nil, fmt.Errorf("token不可用")
             }
         }
     }
 
     // 将token中的claims信息解析出来并断言成用户自定义的有效载荷结构
     if claims, ok := token.Claims.(*CustomClaims); ok && token.Valid {
         return claims, nil
     }
 
     return nil, fmt.Errorf("token无效")
 
 }
定义登陆验证逻辑

接下来的部分就是普通api的具体逻辑了,比如可以在登陆时进行用户校验,成功后未该次认证请求生成token。

 // 定义登陆逻辑
 // model.LoginReq中定义了登陆的请求体(name,passwd)
 func Login(c *gin.Context) {
     var loginReq model.LoginReq
     if c.BindJSON(&loginReq) == nil {
         // 登陆逻辑校验(查库,验证用户是否存在以及登陆信息是否正确)
         isPass, user, err := model.LoginCheck(loginReq)
         // 验证通过后为该次请求生成token
         if isPass {
             generateToken(c, user)
         } else {
             c.JSON(http.StatusOK, gin.H{
                 "status": -1,
                 "msg":    "验证失败" + err.Error(),
                 "data":   nil,
             })
         }
 
     } else {
         c.JSON(http.StatusOK, gin.H{
             "status": -1,
             "msg":    "用户数据解析失败",
             "data":   nil,
         })
     }
 }
 
 // token生成器
 // md 为上面定义好的middleware中间件
 func generateToken(c *gin.Context, user model.User) {
     // 构造SignKey: 签名和解签名需要使用一个值
     j := md.NewJWT()
 
     // 构造用户claims信息(负荷)
     claims := md.CustomClaims{
         user.Name,
         user.Email,
         jwtgo.StandardClaims{
             NotBefore: int64(time.Now().Unix() - 1000), // 签名生效时间
             ExpiresAt: int64(time.Now().Unix() + 3600), // 签名过期时间
             Issuer:    "bgbiao.top",                    // 签名颁发者
         },
     }
 
     // 根据claims生成token对象
     token, err := j.CreateToken(claims)
 
     if err != nil {
         c.JSON(http.StatusOK, gin.H{
             "status": -1,
             "msg":    err.Error(),
             "data":   nil,
         })
     }
 
     log.Println(token)
     // 封装一个响应数据,返回用户名和token
     data := LoginResult{
         Name:  user.Name,
         Token: token,
     }
     
     c.JSON(http.StatusOK, gin.H{
         "status": 0,
         "msg":    "登陆成功",
         "data":   data,
     })
     return
 }
定义普通待验证接口
 // 定义一个普通controller函数,作为一个验证接口逻辑
 func GetDataByTime(c *gin.Context) {
     // 上面我们在JWTAuth()中间中将'claims'写入到gin.Context的指针对象中,因此在这里可以将之解析出来
     claims := c.MustGet("claims").(*md.CustomClaims)
     if claims != nil {
         c.JSON(http.StatusOK, gin.H{
             "status": 0,
             "msg":    "token有效",
             "data":   claims,
         })
     }
 }
 
 // 在主函数中定义路由规则
     router := gin.Default()
     v1 := router.Group("/apis/v1/")
     {
         v1.POST("/register", controller.RegisterUser)
         v1.POST("/login", controller.Login)
     }
     
     // secure v1
     sv1 := router.Group("/apis/v1/auth/")
     // 加载自定义的JWTAuth()中间件,在整个sv1的路由组中都生效
     sv1.Use(md.JWTAuth())
     {
         sv1.GET("/time", controller.GetDataByTime
     }
     router.Run(":8081")
与题目无关的小记录

Go MD5加密字符串

MD5信息摘要算法,一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值(hash value),用于确保信息传输完整一致,MD5算法因其普遍、稳定、快速的特点,仍广泛应用于普通数据的加密保护领域。

Go 语言使用MD5加密主要的是标准库中的"crypto/md5"包来实现的。

通过md5.sum()方法可以将一个比特切片进行Md5加密,返回数据data的MD5校验和。

代码实现:

 package main
 
 import (
     "crypto/md5"
     "fmt"
 )
 
 func MD5(str string) string {
     data := []byte(str) //切片
     has := md5.Sum(data)
     md5str := fmt.Sprintf("%x", has) //将[]byte转成16进制
     return md5str
 }
 
 func main(){
     
     str := MD5("hello")
     fmt.Println(str)
     
 }
posted @ 2023-01-21 23:13  TomiokapEace  阅读(98)  评论(0编辑  收藏  举报