6--JWT学习 | 青训营笔记

123 阅读11分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 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)
      
  }