导航

Golang中的JWT最佳实践

发布时间:6 months ago 更新时间:2 months ago
开发

什么是JWT

JWT(JSON Web Token)全称JSON Web 令牌,理念是通过JSON的方式去传递Web的访问令牌。它是一种开放式的标准(RFC 7519),它定义了一个紧凑的、独立的方式去在多个实体之间通过JSON安全的传输信息。因为这个信息经过了数字签名的校验所以它是被验证的和可被信任的。并且因为Sign的存在,我们能够非常方便的的去检验它的完整性。总结如下:

  1. 紧凑性:JWT的设计目的是紧凑,适合在HTPP请求头或URI查询参数中传输
  2. 独立性:JWT包含所有必要的信息,可以再没有数据库查询的情况下验证其有效性
  3. 安全性:JWT可以被数字签名,确保其完整性和真实性

JWT可以使用HMAC(Hash-based Message Authenticationa Code)算法和非对称加密算法RSA以及ECDSA等进行加密,后面要介绍的GoJWT提供了RegisterSigningMethod方法允许注册自己的签名方法然而,虽然这提供了灵活性,但在Web环境下,建议使用标准的签名方式。

非常抱歉,在开始之前向你提及了多个名词,不过没关系,我会在下面一一解释。

浅谈一下JWT和Token的关系。

Token用户访问服务器需要的令牌(也可以说是密钥),这个令牌可以决定该实体(用户)的权限范围、能否访问、用户真实性等。而JWT是一种构建Token的方式,JWT最终产出的字符串即为Token。

那么Cookie和Token又是什么关系呢?如果你学习过JS你会知道,前端存储数据有两种方式(前端或许不太准确,但为了方便理解还是如此称呼吧):

  1. Cookie:在每次访问同域(Original)的URL时,Agent(一般为Browser)会自动携带该Origin的Cookie一同发送。当然是在Cookie没有过期的前提下,在后端服务器中,可以设置Cookie的有效时长。Token和Cookie都有时长,Token的时长通过JWT的过期时间设定。
  2. LocalStorage:需要JS去调用LocalStorage

所以,Cookie并不是Token,JWT也不是Cookie。Cookie只是一种存储Token的方案,你当然也可以使用LocalStorage去存储,相对的加前端代码即可。并且需要手动管理LocalStorage中的数据删除和存储。

构成

JWT 由以下三部分构成,这些部分通过.去进行分隔,最终组成Base64(Header).Base64(Payload).Signed(Base64(Header).Base64(Payload))这样的格式:

  1. Header(头部):包含令牌的元数据,如使用的签名算法
  2. Payload(荷载):包含声明(Claims),这些声明是关于实体(通常是用户)的声明
  3. Signature(签名):用于验证JWT的完整性和真实性

这个Header部分包含了2个东西,token的类型,是JWT,还有使用的签名算法例如:HMACSHA256RSA

{
    "alg": "HS256", 
    "typ": "JWT"
}

然后使用 Base64 去编码该部分形成第一部分

Payload

Token 的第二部分是payload,包含有多个Claim。

Claim的翻译为(n. 声称,断言;索要,索赔;权利,所有权;要求得到的土地使用权;专利新特征申明)。

通常 Claim 是一个关于实体(通常是用户)的身份和一些附加数据的声明。

有三种类型的Claim:

  • Registed claims:是JWT预设的一些Claims,不是必须的但是推荐。它提供了一套有用的、统一的Claim。其中的一些是:

    1. iss(issuer)
    2. exp(expiration time)
    3. aud(audience)
    4. (Other)[https://datatracker.ietf.org/doc/html/rfc7519#section-4.1]

    注意:这些claim的key长度只有三个字符长,目的就是为了精简,而我们在日常开发中也建议如此

  • Public claims:这些是通过JWT的开发者定义的Claim。

  • Private claims:这些自定义声明是为了在同意使用它们的各方之间共享信息而创建的,既不是注册声明,也不是公共声明。

同样的,这一部分也通过Base64编码

接下来详细的描述Claim:

iss(Issuer)

iss 标识了使用该JWT的发布者,指明该JWT的发布者,该Claim一般是由生成的应用指定的。

sub(Subject)

sub 标识的是这个JWT的主题,一般情况下我们将这个设置为用户的ID,也可以将它设置为跟它名称一样并用Public Claims部分去传输信息,由sub去决定信息的主题

aud(Audience)

aud 指定了能够使用该JWT的实体,URL和字符串都可以。该字段中存储的通常是一个字符串数组。当我们的JWT只希望某一个实体去使用时,我们会设置为单个字符串。在GoJWT中,推荐使用ClaimStrings类型去存储该Claim的值。

exp(Expiration Time)

exp 声明标识了该JWT在多久的时间或多久时间以后不能再被处理。这意味着你可以把这个值设置为过期的时间点,也可以使用这个Claim设置多久时间点过期。这个Claim的值必须包含一个NumericDate类型的值。

nbf(Not Before)

nbf 声明标识了这个JWT在这个时间点之前都不能被处理,只有当当前时间大于或等于nbf时才能被处理。这个Claim的值必须包含一个NumericDate类型的值。

iat(Issued At)

iat 声明标识了这个JWT的发布时间点。它一般用于确定该JWT的年龄,并和nbfClaim配合使用。这个Claim的值必须包含一个NumericDate类型的值。

jti(JWT ID)

jti 声明标志了一个对于该JWT的唯一标识符,它确保每一个JWT都能够被唯一标识,重要的是预防JWT重播攻击(即使用已经过期JWT中的信息,更换过期时间,以实现重播)。主要的预防是通过服务器存储jti,去管理每一个jti的过期时间。

以上就是RPC中规定的Standard Claims

Signature

要创建签名部分,必须使用已编码的Header、已编码的Payload、一个SecretHeader中指定的算法,并对其进行签名。

Golang

Golang中有一个库GoJWT依照RFC 7519中的规范,编写了一系列的原型函数,GoJWT 库是较简单的一个库,很容易理解,核心的方法就那么几种,所以无需担心过高的学习成本。

(签名方法)Signing Method

GoJWT 提供了如下几种签名方法,若认为以下的签名方法不足以满足的业务需求,那么可以使用RegisterSigningMethod()方法去注册你自己的Method,但必须实现SigningMethod接口。

名称 alg 声明值 Signing Key 类型 Verification Key 类型
HMAC HS256,HS384,HS512 []byte []byte
RSA RS256,RS384,RS512 *rsa.PrivateKey *rsa.PublicKey
ECDSA ES256,ES384,ES512 *ecdsa.PrivateKey *ecdsa.PublicKey
RSA-PSS PS256,PS384,PS512 *rsa.PrivateKey *rsa.PublicKey
EdDSA EdDSA ed25519.PrivateKey ed25519.PublicKey

New

通过jwt.New函数去创建一个token,需要提供一个签名方法,使用 SignedString 去签名并转换Token为一个字符串格式。这种方式使用一个默认的空的Claims列表。下面使用了 ECDSA 签名方法作为演示,仅仅是为了演示Token的生成。

package main
import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "fmt" "github.com/golang-jwt/jwt" ) var ( key *ecdsa.PrivateKey t *jwt.Token s string err error ) func main() { key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { panic(err) } t = jwt.New(jwt.SigningMethodES256) s, err = t.SignedString(key) if err != nil { panic(err) } fmt.Println(s) }

NewWithClaims

上面的步骤通过jwt.New创建了一个有效的Token。现在通过jwt.NewWithClaims创建一个包含有#Public Claims的JWT。该演示中我们使用 HMAC 签名方法。

package main

import (
	"crypto/rand"
	"fmt"
	"log"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

type CustomClaims struct {
	Admin bool `json:"admin"`
	jwt.RegisteredClaims
}

var key []byte

// 该函数用于jwt.Parse()传入的RetKey函数
// 该函数的目的是,在GoJWT去调用验证函数前
// 我们提取CustomClaims去做我们自己的校验
// 如果校验成功再返回密钥去校验 RegisteredClaims
// 在使用Parse()系列函数时,Claims结构体必须包含有标准
// 的Claims,标准的Claims可以通过 RegisteredClaims 去获取
// 是否过期、是否能够使用、某些Claim是否存在等
func KeyVerifyFunc(token *jwt.Token) (any, error) {
	return key, nil
}

func NewUUID() (uuid string) {
	b := make([]byte, 16)
	fmt.Println(b)
	_, err := rand.Read(b)
	if err != nil {
		log.Fatal(err)
	}
	uuid = fmt.Sprintf("%x-%x-%x-%x-%x",
		b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
	return
}

// HMAC
func main() {

	key = make([]byte, 32)
	if _, err := rand.Read(key); err != nil {
		log.Fatalln(err)
	}

	claims := CustomClaims{
		true,
		jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(4 * time.Second)), // GoJWT 中所有表示时间的Claim都为 NumericDate,以满足RPC中标准
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Subject:   "1",
			ID:        NewUUID(),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	signedToken, err := token.SignedString(key)
	if err != nil {
		log.Fatalln(err)
	}

	fmt.Println(signedToken)

	// 解析器本身只提供解析Token的功能
	// 如果需要校验Payload中的某些 Claim
	// 需要传入 Option() 系列函数
	// 例如下方就校验了Token是否过期
	// 如果你需要校验某些Claim的值是否为特定值
	// GoJWT 也提供了方法
	// 例如:WithSubject()方法,就提供了验证 `sub`
	// 的值是否为 `1` 的功能
	parser := jwt.NewParser(jwt.WithExpirationRequired(), jwt.WithSubject("1"))

	time.Sleep(3 * time.Second)

	// 写法1
	// func (p *jwt.Parser) ParseWithClaims(tokenString string, claims jwt.Claims, keyFunc jwt.Keyfunc) (*jwt.Token, error)
	// jwt.Claims 类型是一个空指针,基本能够支持传任意类型:
	// 1. 规定传入的是一个 Claims,否则意义不明
	// 2. 空指针可以传入任意参数
	// 3. 传入的类型必须是支持反射的数据结构体
	// jwt.Keyfunc(*jwt.Token) (any, error)
	ParsedToken, err := parser.ParseWithClaims(signedToken, &CustomClaims{}, KeyVerifyFunc)

	// 写法2
	// 当我们的验证函数机制简单时,我们可以直接使用匿名函数
	// 但是,当我们的校验逻辑复杂时,还是建议新建一个单独的方法
	// ParsedToken, err := parser.ParseWithClaims(signedToken, &Claims{}, func(toeken *jwt.Token) (any, error) {
	// 		return key, nil
	// })

	if ParsedToken.Valid && err == nil {
		if user, ok := ParsedToken.Claims.(*CustomClaims); ok {
			if user.Admin {
				fmt.Printf("管理员[%s] 你好\n", user.Subject)
			} else {
				fmt.Printf("用户[%s] 你好\n", user.Subject)
			}
		}
	} else {
		fmt.Println(err)
	}
}

基本上GoJWT需要注意的就以上注释中的内容。

参考

Wiki:https://zh.wikipedia.org/wiki/HMAC

Golang-jwt: https://golang-jwt.github.io/jwt/

Golang Pkg:https://pkg.go.dev/github.com/golang-jwt/jwt/v5@v5.2.1

JWT-IO:https://jwt.io/introduction