前言

在当今微服务和分布式系统盛行的背景下,安全、高效的用户身份验证机制显得尤为重要。为了有效管理用户的访问权限并验证用户身份,我们经常会采用各种身份验证方案。而 JSON Web Tokens(JWT)便是其中一种流行的技术,因其简洁、灵活且易于跨语言实现的特性,被广泛应用于系统的身份验证和信息交换。

本文旨在介绍如何在 Go 语言中使用 JWT 。内容将涵盖 JWT的简单介绍、安装 Go JWT 模块、创建 JWT 对象、生成 JWT 字符串以及解析 JWT 字符串等方面。

JWT

JSON Web Token(JWT)是一种开放标准(RFC 7519),用于在网络应用环境间安全地传递声明(claims)。JWT 是一种紧凑且自包含的方式,用于作为 JSON 对象在各方之间安全地传输信息。由于其信息是经过数字签名的,所以可以确保发送的数据在传输过程中未被篡改。

JWT 由三个部分组成,它们之间用 . 分隔,格式如下:Header.Payload.Signature → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJQcm9ncmFtbWVyIiwiaXNzIjoi56iL5bqP5ZGY6ZmI5piO5YuHIiwic3ViIjoiY2hlbm1pbmd5b25nLmNuIn0.uRnH-rUb7lsZtQ11o8wXjIOJnIMBxszkvU1gY6hCGjo,下面对每个部分的进行简要介绍:

  1. Header(头部):Hedaer部分用于描述该 JWT的基本信息,比如其类型(通常是 JWT)以及所使用的签名算法(如 HMAC SHA256RSA)。
  1. Payload(负载):Payload 部分包含所传递的声明。声明是关于实体(通常是用户)和其他数据的语句。声明可以分为三种类型:注册声明、公共声明 和 私有声明。
  • 注册声明:这些声明是预定义的,非必须使用的但被推荐使用。官方标准定义的注册声明有 7 个:
Claim(声明) 含义
iss(Issuer) 发行者,标识 JWT 的发行者。
sub(Subject) 主题,标识 JWT 的主题,通常指用户的唯一标识
aud(Audience) 观众,标识 JWT的接收者
exp(Expiration Time) 过期时间。标识 JWT 的过期时间,这个时间必须是将来的
nbf(Not Before) 不可用时间。在此时间之前,JWT 不应被接受处理
iat(Issued At) 发行时间,标识 JWT 的发行时间
jti(JWT ID) JWT 的唯一标识符,用于防止 JWT 被重放(即重复使用)
  • 公共声明:可以由使用 JWT的人自定义,但为了避免冲突,任何新定义的声明都应已在IANA JSON Web Token Registry中注册或者是一个 公共名称,其中包含了碰撞防抗性名称(Collision-Resistant Name)。
  • 私有声明:发行和使用 JWT的双方共同商定的声明,区别于 注册声明 和 公共声明。
  1. Signature(签名):为了防止数据篡改,将头部和负载的信息进行一定算法处理,加上一个密钥,最后生成签名。如果使用的是 HMAC SHA256算法,那么签名就是将编码后的头部、编码后的负载拼接起来,通过密钥进行HMAC SHA256 运算后的结果。

实战

安装

通过以下命令在 Go 程序里安装 Go JWT 依赖:

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

创建 Token(JWT) 对象

生成 JWT 字符串首先需要创建 Token 对象(代表着一个 JWT)。因此我们需要先了解如何创建 Token 对象。jwt 库主要通过两个函数来创建 Token 对象:NewWithClaimsNew

NewWithClaims 函数

jwt.NewWithClaims 函数用于创建一个 Token 对象,该函数允许指定一个签名方法和一组声明claims)以及可变参数 TokenOption。下面是该函数的签名:

NewWithClaims(method SigningMethod, claims Claims, opts ...TokenOption) *Token
  • method:这是一个 SigningMethod 接口参数,用于指定 JWT 的签名算法。常用的签名算法有 SigningMethodHS256SigningMethodRS256等。这些算法分别代表不同的签名技术,如 HMACRSA
  • claims:这是一个 Claims接口参数,它表示 JWT 的声明。在 jwt 库中,预定义了一些结构体来实现这个接口,例如 RegisteredClaimsMapClaims 等,通过指定Claims 的实现作为参数,我们可以为JWT 添加声明信息,例如发行人(iss)、主题(sub)等。
  • opts:这是一个可变参数,允许传递零个或多个 TokenOption类型参数。TokenOption是一个函数,它接收一个 *Token,这样就可以在创建 Token 的时候对其进行进一步的配置。

使用示例

// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/jwt/create-token/new_with_claims.go
package main

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

func main() {
    mapClaims := jwt.MapClaims{
        "iss": "程序员陈明勇",
        "sub": "chenmingyong.cn",
        "aud": "Programmer",
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, mapClaims)
    fmt.Println(token != nil) // true
}

这段代码首先构建了包含发行者(iss)、主题(sub)和观众(aud)信息的 MapClaims类型声明。

然后,通过调用 jwt.NewWithClaims函数,并将 jwt.SigningMethodHS256作为签名方法和之前构建的 mapClaims 作为参数传递,来创建了一个新的Token 实例。

New 函数

jwt.New函数用于创建一个Token 对象,该函数允许指定一个签名方法和可变参数 TokenOption。下面是该函数的源码:

func New(method SigningMethod, opts ...TokenOption) *Token {
    return NewWithClaims(method, MapClaims{}, opts...)
}

通过源码我们可以发现,该函数内部的实现通过调用NewWithClaims函数,并默认传入一个空的 MapClaims 对象,从而生成一个 Token 对象。
使用示例

// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/jwt/create-token/new.go

package main

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

func main() {
    token := jwt.New(jwt.SigningMethodHS256)
    fmt.Println(token != nil) // true
}

生成 JWT 字符串

通过使用 jwt.Token 对象的 SignedString 方法,我们能够对 JWT 对象进行序列化和签名处理,以生成最终的 token 字符串。该方法的签名如下:

func (t *Token) SignedString(key interface{}) (string, error)
  • key:该参数是用于签名 token 的密钥。密钥的类型取决于使用的签名算法。例如,如果使用 HMAC 算法(如 HS256HS384 等),key 应该是一个对称密钥(通常是 []byte 类型的密钥)。如果使用 RSA 或 ECDSA 签名算法(如 RS256、ES256),key 应该是一个私钥 *rsa.PrivateKey*ecdsa.PrivateKey
  • 方法返回两个值:一个是成功签名后的 JWT 字符串,另一个是在签名过程中遇到的任何错误。

使用示例

// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/jwt/generage-token/generate_token.go

package main

import (
    "crypto/rand"
    "fmt"
    "github.com/golang-jwt/jwt/v5"
)

func GenerateJwt(key any, method jwt.SigningMethod, claims jwt.Claims) (string, error) {
    token := jwt.NewWithClaims(method, claims)
    return token.SignedString(key)
}

func main() {
    jwtKey := make([]byte, 32) // 生成32字节(256位)的密钥
    if _, err := rand.Read(jwtKey); err != nil {
        panic(err)
    }
    jwtStr, err := GenerateJwt(jwtKey, jwt.SigningMethodHS256, jwt.MapClaims{
        "iss": "程序员陈明勇",
        "sub": "chenmingyong.cn",
        "aud": "Programmer",
    })
    if err != nil {
        panic(err)
    }
    fmt.Println(jwtStr)
}

这段代码首先声明并初始化一个长度为 32 字节的 byte 切片,然后使用 crypto/rand 库的 Read 函数填充切片(即密钥),确保生成的密钥具有高强度的随机性和不可预测性。

然后,调用 GenerateJwt 函数,传入 jwtKey、jwt.SigningMethodHS256 签名方法和包含特定声明的 MapClaims 对象,以创建 JWT 字符串。

在 GenerateJwt 函数内部,它利用 token.SignedString 方法和提供的 key 生成并返回签名的 JWT 字符串。

解析 JWT 字符串

jwt 库主要通过两个函数来解析 jwt 字符串:Parse 和 ParseWithClaims。

Parse 函数

Parse 函数用于解析 JWT 字符串,函数签名如下:

func Parse(tokenString string, keyFunc Keyfunc, options ...ParserOption) (*Token, error)
  • tokenString:要解析的 JWT 字符串。

-keyFunc:这是一个回调函数,返回用于验证 JWT 签名的密钥。该函数签名为func(*Token) (interface{}, error)。这种设计,有利于我们根据 token 对象的信息返回正确的密钥。例如我们可能有一个 keyMap 对象,类型为 map,该对象用于保存多个 key 的映射,通过 Token 对象的信息,拿到某个标识,就能通过 keyMap 获取到正确的密钥。

  • options:这是一个可变参数。允许传递零个或多个 ParserOption 类型参数。这些选项可以用来定制解析器的行为,如设置 exp 声明为必需的参数,否则解析失败。

使用示例

// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/jwt/parse-token/parse.go

package main

import (
    "crypto/rand"
    "errors"
    "fmt"
    "github.com/golang-jwt/jwt/v5"
    "time"
)

func ParseJwt(key any, jwtStr string, options ...jwt.ParserOption) (jwt.Claims, error) {
    token, err := jwt.Parse(jwtStr, func(token *jwt.Token) (interface{}, error) {
        return key, nil
    }, options...)
    if err != nil {
        return nil, err
    }
    // 校验 Claims 对象是否有效,基于 exp(过期时间),nbf(不早于),iat(签发时间)等进行判断(如果有这些声明的话)。
    if !token.Valid {
        return nil, errors.New("invalid token")
    }
    return token.Claims, nil
}

func main() {
    jwtKey := make([]byte, 32) // 生成32字节(256位)的密钥
    if _, err := rand.Read(jwtKey); err != nil {
        panic(err)
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "iss": "程序员陈明勇",
        "sub": "chenmingyong.cn",
        "aud": "Programmer",
        "exp": time.Now().Add(time.Second * 10).UnixMilli(),
    })
    jwtStr, err := token.SignedString(jwtKey)
    if err != nil {
        panic(err)
    }

    // 解析 jwt
    claims, err := ParseJwt(jwtKey, jwtStr, jwt.WithExpirationRequired())
    if err != nil {
        panic(err)
    }
    fmt.Println(claims)
}

这段代码的重点是自定义的 ParseJwt 函数,它负责解析 JWT 字符串,并根据验证结果返回 Claims 数据和一个可能的存在的错误。ParseJwt 函数内部利用 jwt.Parse 解析 JWT 字符串。解析后,函数检查得到的 token 对象的 Valid 属性以确认 Claims 是否有效。有效性检查包括但不限于验证签名、检查 token 是否过期。如果 token 通过所有验证,函数返回 Claims 数据;如果验证失败(如签名不匹配或 token 已过期),则返回错误。

ParseWithClaims 函数

ParseWithClaims 函数类似 Parse,函数签名如下:

func ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc, options ...ParserOption) (*Token, error)
  • tokenString:要解析的 JWT 字符串。
  • claims:这是一个 Claims 接口参数,用于接收解析 JWT 后的 claims 数据。
  • keyFunc:与 Parse 函数中的相同,用于提供验证签名所需的密钥。
  • options:与 Parse 函数中的相同,用来定制解析器的行为.

使用示例

// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/jwt/parse-token/parse_with_claims.go

package main

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

func ParseJwtWithClaims(key any, jwtStr string, options ...jwt.ParserOption) (jwt.Claims, error) {
    mc := jwt.MapClaims{}
    token, err := jwt.ParseWithClaims(jwtStr, mc, func(token *jwt.Token) (interface{}, error) {
       return key, nil
    }, options...)
    if err != nil {
       return nil, err
    }
    // 校验 Claims 对象是否有效,基于 exp(过期时间),nbf(不早于),iat(签发时间)等进行判断(如果有这些声明的话)。
    if !token.Valid {
       return nil, errors.New("invalid token")
    }
    return token.Claims, nil
}

func main() {
    jwtKey := make([]byte, 32) // 生成32字节(256位)的密钥
    if _, err := rand.Read(jwtKey); err != nil {
       panic(err)
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
       "iss": "程序员陈明勇",
       "sub": "chenmingyong.cn",
       "aud": "Programmer",
    })
    jwtStr, err := token.SignedString(jwtKey)
    if err != nil {
       panic(err)
    }

    // 解析 jwt
    claims, err := ParseJwtWithClaims(jwtKey, jwtStr)
    if err != nil {
       panic(err)
    }
    fmt.Println(claims) // map[aud:Programmer iss:程序员陈明勇 sub:chenmingyong.cn]
}

这段代码中的 ParseJwtWithClaims 函数与之前示例中的 ParseJwt 函数功能类似,都是负责解析 JWT 字符串,并根据验证结果返回 Claims 数据和一个可能的存在的错误。不同之处在于,ParseJwtWithClaims 函数内部使用了 jwt.ParseWithClaims 函数来解析 JWT 字符串,这额外要求我们提供一个 Claims 实例来接收解析后的 claims 数据。在此示例中,通过 jwt.MapClaims 提供了这一实例。

转:https://juejin.cn/post/7342766597243207715