前言
在当今微服务和分布式系统盛行的背景下,安全、高效的用户身份验证机制显得尤为重要。为了有效管理用户的访问权限并验证用户身份,我们经常会采用各种身份验证方案。而 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
,下面对每个部分的进行简要介绍:
Header
(头部):Hedaer
部分用于描述该JWT
的基本信息,比如其类型(通常是JWT
)以及所使用的签名算法(如HMAC SHA256
或RSA
)。
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
的双方共同商定的声明,区别于 注册声明 和 公共声明。
- 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 对象:NewWithClaims
和New
。
NewWithClaims 函数
jwt.NewWithClaims
函数用于创建一个 Token 对象,该函数允许指定一个签名方法和一组声明claims)以及可变参数 TokenOption
。下面是该函数的签名:
NewWithClaims(method SigningMethod, claims Claims, opts ...TokenOption) *Token
method
:这是一个SigningMethod
接口参数,用于指定 JWT 的签名算法。常用的签名算法有SigningMethodHS256
、SigningMethodRS256
等。这些算法分别代表不同的签名技术,如HMAC
、RSA
。claims
:这是一个Claims
接口参数,它表示 JWT 的声明。在 jwt 库中,预定义了一些结构体来实现这个接口,例如RegisteredClaims
和MapClaims
等,通过指定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 算法(如
HS256
、HS384
等),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 提供了这一实例。