JWT


JWT - Json Web Token 官网已经讲的很清楚了其实

说白了就是使用某些方法得到一个token,然后通过这个token来验证用户的真假

这边主要介绍JWT来鉴权的知识,一般是作为中间件使用

  • 结构

    JSON Web 令牌由三个部分组成,由 . 分隔

    • Header
    • Payload
    • Signature

    通常表示为 xxxxxxxx.xxxxxxxx.xxxxxxxxx

    三个字段都是JSON格式的,然后经过BASE64编码后获得下面这个

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

    反正一坨你也看不懂,但是登录一个网址你就可以转换了 JSON Web Tokens - jwt.io

    转换出来的格式就是刚才的三个格式

  • Header 由 令牌类型签名算法 组成

    {

    “alg”: “HS256”,

    “typ”: “JWT”

    }

  • Payload

    由英文意思得,就是有效负载,在这个字段中会承载着最有用的信息

    但是一般不放机密信息,并且这个字段的信息承载不能过多数据

    里面大致有这些数据

    • 预定义声明(已注册声明)
    简称 全称 含义
    iss Issuer 发行方
    sub Subject 主体
    aud Audience (接受)目标方
    exp Expiration Time 过期时间
    nbf Not Before 早于定义的时间的JWT不能处理
    iat Issued At JWT发行的时间戳
    jti JWT ID JWT的唯一标识
    • 公共声明

      服务器自己定义,比如说 后面我自己定义一个user啥的

    • 私人声明

      这是为在同意使用他们各方之间共享信息而创建的自定义声明

    示例:

    {

    ​ “sub”: “1234567890”,

    ​ “name”: “John Doe”,

    ​ “admin”: true

    }

  • Signature

    我们需要获得 header 和 payload

    签名算法一般是 HMAC(HS256)RSA(RS256)

    首先,使用Base64 URL编码的头部和有效负载创建一个消息。消息的格式通常是 **base64UrlEncode(header) + "." + base64UrlEncode(payload)**。

    使用指定的算法(在JWT的头部中指定)对消息进行签名

    将生成的签名附加到JWT的尾部,形成最终的JWT。

    示例:

    1. 创建消息:**base64UrlEncode(header) + "." + base64UrlEncode(payload)**,假设编码后的头部为 **eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9**,编码后的有效负载为 **eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogIkpvaG4gRG9lIiwgImFkbWluIjogdHJ1ZQ==**,那么消息就是 **eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogIkpvaG4gRG9lIiwgImFkbWluIjogdHJ1ZQ==**。
    2. 应用算法:使用HMAC SHA-256算法和密钥对消息进行签名,得到签名值。
    3. 添加签名:将签名值附加到JWT的尾部,形成最终的JWT。

    反正就是 组合加签名

工作原理


首先为什么要用JWT,因为Session不好用,为什么Session不好用

HTTP协议是无状态的,也就是说,如果我们已经认证了一个用户

那么他下一次请求的时候,服务器不知道我是谁,我们必须再次认证

  • Session

    1、用户向服务器发送用户名和密码。

    2、服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。

    3、服务器向用户返回一个 session_id,写入用户的 Cookie。

    4、用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。

    5、服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

    这种模式的问题在于扩展性不好,如果是服务器集群,或者跨域的服务导向结构,需要session共享

    如果实现session共享,可以使用数据库或者别的持久层,但是这样子依赖持久层,工程量大,一旦持久层崩溃,就没了。

  • JWT

    服务器认证后,生成一个token,发回给用户,以后通信的时候用户只需要发送token即可

    这相当于间接地将验证数据传递到了用户,服务器的压力会小很多

    为了防止用户篡改数据,服务器在生成token的时候需要用到Signature

    1,浏览器发起请求登陆,携带用户名和密码;

    2,服务端验证身份,根据算法,将用户标识符打包生成 token,

    3,服务器返回JWT信息给浏览器,JWT不包含敏感信息;

    4,浏览器发起请求获取用户资料,把刚刚拿到的 token一起发送给服务器;

    5,服务器发现数据中有 token,验明正身;

    6,服务器返回该用户的用户资料;

  • 区别

    (1) session 存储在服务端占用服务器资源,而 JWT 存储在客户端

    (2) session 存储在 Cookie 中,存在伪造跨站请求伪造攻击的风险

    (3) session 只存在一台服务器上,那么下次请求就必须请求这台服务器,不利于分布式应用

    (4) 存储在客户端的 JWT 比存储在服务端的 session 更具有扩展性

代码呈现


这个是Gin-Gorm下的实现gin-gorm功能的使用

其实一开始我只是想着写一个修改密码的案例,但是想着想着感觉不对啊,我们平时修改密码都是先登录,然后在登录状态下修改密码的,因此我就找到了JWT

  • 下依赖

    1
    go get -u github.com/golang-jwt/jwt/v5

    库更新了,网上很多代码都有小毛病,泪目。。

  • Router 路由

    1
    rootPath.PUT("/update", middleware.Auth(), handler.Update)

    鉴权 要放在中间件里面

  • NewToken 生成令牌

    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
    package jwt

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

    var jwtKey = []byte("ItIsSecret") // 密钥是一个字节数组

    // 把claims作为一个结构体
    type Payload struct {
    Authorized bool `json:"authorized"`
    User string `json:"user"`
    }

    type MyCustomClaims struct {
    Payload
    jwt.RegisteredClaims
    }

    func NewToken(name string) (string, error) {

    //设置一些预定义 Payload
    claims := &MyCustomClaims{
    Payload: Payload{
    Authorized: true,
    User: name,
    },
    RegisteredClaims: jwt.RegisteredClaims{
    Issuer: "Echin",
    Subject: "Tom",
    ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
    // jwt.NewNumericDate 可以创建一个符合JWT标准的时间格式
    },
    }

    // 创建一个新的令牌
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) // Header,token是一个对象

    //签名并获取完整的编码令牌作为字符串 Signature
    tokenString, err := token.SignedString(jwtKey)
    if err != nil {
    return "", err
    }

    return tokenString, nil
    }
    1. 这里基本上使用Claims来标识Payload了,赋值的时候把用户的name给user
    2. 按照刚才原理的介绍,生成一个token就直接使用jwt.NewWithClaims(算法,负载)
    3. 重要的是我这边负载使用的是结构体的形式(感谢NX模板),之前使用的是map的形式,但是发现他使得token后面传递值时候的类型断言有点问题,所以改成了结构体的形式。
    4. 密钥必须是一个 字节数组
    5. 时间传递的时候需要使用时间戳
    6. 还有一个很重要的,因为版本的原因很多网上预定义类型为jwt.StandardClaims,但是最新版本的类型是jwt.RegisterClaims
    7. 最后把返回一个tokenString,代码逻辑要注意: 生成token和验证token就是 *token与string之间的转化,要发送给客户端就需要使用string类型,但是到时候自己验证token的时候就需要string类型转化称为 *token 类型
  • ParseToken 验证token

    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
    package jwt

    import (
    "errors"
    "fmt"
    "github.com/golang-jwt/jwt/v5"
    "strings"
    )

    // ParseToken 是 解析令牌
    func ParseToken(bearerToken string) (*MyCustomClaims, error) {
    // 解析方式需要添加 Bearer token模式
    tokenParts := strings.Split(bearerToken, " ") //通过空格分隔出两个部分,并且存入数组之中
    if len(tokenParts) != 2 || strings.ToLower(tokenParts[0]) != "bearer" { // strings.ToLower会把字符串变成小写的形式
    return nil, errors.New("Invalid token format,you need add bearer")
    }
    tokenString := tokenParts[1]

    // 解析后续token
    claims := &MyCustomClaims{}
    // 是*token和string之间的转换
    // 这是一个回调函数具体结构就是 jwt.Parse(string,KeyFunc)
    _, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
    // 验证签名方法 HMAC-SHA56签名方法
    if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
    return nil, errors.New("Unexpected Signing Method")
    }
    return jwtKey, nil
    })
    if err != nil {
    fmt.Println(err)
    return nil, err
    }
    return claims, nil
    }
    1. 由于 http头部往往是 http Bearer 因此在真实传递参数时需要先解析Bearer+空格这个字段,而后去解析token
    2. 解析token就是使用了jwt.ParseWithClaims,因为传递的时claims的指针,claims直接获得值,后来就是使用claims.user中的值来鉴权的,因此返回claims就行,也不需要token变量
    3. 回调函数的结构还是有点蒙蔽的
  • Auth 中间件鉴权

    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
    package middleware

    import (
    "LearningGo/jwt"
    "github.com/gin-gonic/gin"
    "net/http"
    )

    func Auth() gin.HandlerFunc {
    return func(c *gin.Context) {
    token := c.GetHeader("Authorization")
    if token == "" {
    c.JSON(http.StatusUnauthorized, gin.H{
    "error": "Failed to fetch token",
    })
    return
    }
    parseToken, err := jwt.ParseToken(token)
    if err != nil {
    c.JSON(http.StatusUnauthorized, gin.H{
    "msg": "Failed to Auth",
    "error": err,
    })
    c.Abort()
    return
    }
    c.Set("Payload", parseToken)
    c.Next()
    }
    }
    1. 获取http头部字段就直接使用c.GetHeader(“Authorization”),记得不要写成token,因为APIfox没有选项,并且在测试的时候记得在Header中测试,而不是Auth上面测试
    2. c.Next c.Abort c.Set 是中间件的知识,不明白可以去查资料
    3. 为什么要把jwt放到中间件,最重要的是有c.Set(“key”,value),作用是把键值对放入上下文(gin.Context)中,这样子后续的函数就可以通过c.Get()获得,这个后面再说
    4. 记住c.GetHeader(“Authorization”)返回的是一个字符串,要解析token就是把他变成一个*token的形式,用这个类型去传递值,毕竟一坨字符串根本看不懂
  • Login 登录

    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 handler

    import (
    "LearningGo/db"
    "LearningGo/jwt"
    "LearningGo/model"
    "github.com/gin-gonic/gin"
    "net/http"
    )

    // 用户的登录
    func Login(c *gin.Context) {
    // 用PostForm 传输数据
    name := c.PostForm("name")
    password := c.PostForm("password")

    var v2 model.User
    // 判断用户是否存在
    if tx := db.DB.Where(" name = ? ", name).First(&v2); tx.Error != nil {
    c.JSON(http.StatusBadRequest, gin.H{
    "msg": "用户不存在",
    "err": tx.Error,
    })
    return
    }

    // 判断密码是否正确
    if password != v2.Password {
    c.JSON(http.StatusBadRequest, gin.H{
    "err": "密码不正确,小笨蛋",
    })
    return
    } else {
    token, err := jwt.NewToken(name)
    if err != nil {
    c.JSON(http.StatusUnauthorized, gin.H{
    "msg": "生成token失败",
    "error": err.Error(),
    })
    return
    }
    c.JSON(http.StatusOK, gin.H{
    "msg": "登录成功,大聪明",
    "user": v2,
    "token": token,
    })
    }
    }

    这个就是生成令牌的作用地方,当然也是我测试的时候知道token到底为多少的地方,不知道token我就没法验证token了,你说呢?

  • update 修改密码

    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
    package handler

    import (
    "LearningGo/db"
    "LearningGo/jwt"
    "LearningGo/model"
    "github.com/gin-gonic/gin"
    "net/http"
    )

    // 修改密码

    func Update(c *gin.Context) {
    // 输入老的密码和新的密码
    NewPassword := c.PostForm("NewPassword")
    payload, exists := c.Get("Payload")
    if !exists {
    c.JSON(http.StatusBadRequest, gin.H{
    "msg": "账号未登录",
    })
    return
    }
    load := payload.(*jwt.MyCustomClaims)
    db.DB.Model(&model.User{}).Where("name = ? ", load.User).Update("password", NewPassword)
    c.JSON(http.StatusOK, gin.H{
    "msg": "修改密码成功",
    "user": load.User,
    "NewPassword": NewPassword,
    })
    }

    c.Get(“key”)是从上下文中获取数值,但是这边获得的数值类型是Any,因此需要给Payload进行类型断言(这个东西超级好用,但是别天天对着接口类型断言….),因为Payload本身形式就是jwt.MyCustomClaims的,本来结构体就是这么定义的,这样就可以顺其自然的获得token的user信息,再通过user信息进行数据库操作,这也没啥好说的反正。

总结


  1. JWT就是生成验证令牌,放到中间件里面使用,完了。
  2. 项目驱动学习
  3. GPT+Github+BING
  4. 问鸟鸟和NX,虽然他们每次提出的点我都Get不到,会了以后就Get到了,特别是NX每次都言简意赅,听都听不懂,唉。。。