jwt在go中的应用

发布时间 2023-12-01 17:47:24作者: 厚礼蝎

官网

JWT

什么是 JWT

在现代的 Web 应用开发中,目前已经有大半部分的应用都是使用的 jwt 的方式来做登录鉴权功能,那么什么是 jwt 呢?

  • JSON Web Token(JWT) 是一个开放标准 RFC 519,它定义了一种紧凑且自包含的方式,用于作为 JSON 对象在各方面之间安全地传输信息;
  • JWT 是一个数字签名,生成的信息是可以验证并被信任的;
  • 它可以使用 HMAC 算法或使用 RSAECDSA 的公钥/私钥对对 jwt 进行签名;
  • JWT 是目前最流行的跨域认证解决方案;

什么时候用到 JWT

这里有一些场景下可以使用到 jwt,具体如下:

  • Authorization: 这是使用 jwt 最常见的场景。一次登录,之后的每次请求都会包含 JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是目前广泛使用 JWT 的一个特性,因为它开销很小,并且能够轻松地跨不同的域使用;
  • 信息交换: jwt 是在各方之间安全传输信息的好方法。因为 jwt 可以进行签名,例如使用 公钥/私钥对,所以您可以确定发送者就是就是本人。此外,由于签名是使用 headerpayload 计算的,因此您还可以验证内容是否未被篡改;

JWT的优点

  • 支持跨域访问:cookie是无法跨域的,而token由于没有用到cookie(前提是将token放到请求头中),所以跨域后不会存在信息丢失问题
  • 无状态:token机制在服务端不需要存储session信息,因为token自身包含了所有登录用户的信息,所以可以减轻服务端压力
  • 更适用CDN:可以通过内容分发网络请求服务端的所有资料
  • 更适用于移动端:当客户端是非浏览器平台时,cookie是不被支持的,此时采用token认证方式会简单很多
  • 无需考虑CSRF:由于不再依赖cookie,所以采用token认证方式不会发生CSRF,所以也就无需考虑CSRF的防御

JWT通常的认证流程

通俗地说,JWT的本质就是一个字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json对象传输。

JWT的认证流程如下:

  1. 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口,这个过程一般是一个POST请求。建议的方式是通过SSL加密的传输(HTTPS),从而避免敏感信息被嗅探
  2. 后端核对用户名和密码成功后,将包含用户信息的数据作为JWT的Payload,将其与JWT Header分别进行Base64编码拼接后签名,形成一个JWT Token,形成的JWT Token就是一个如同 lll.zzz.xxx 的字符串
  3. 后端将JWT Token字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在浏览器中,退出登录时删除保存的JWT Token即可
  4. 前端在每次请求时将 JWT Token 放入HTTP请求头中的 Authorization 属性中(解决XSS和XSRF问题)
  5. 后端检查前端传过来的JWT Token,验证其有效性,比如检查签名是否正确、是否过期、token的接收方是否是自己等等
  6. 验证通过后,后端解析出JWT Token中包含的用户信息,进行其他逻辑操作(一般是根据用户信息得到权限等),返回结果

为什么要用JWT

传统Session认证的弊端

我们知道HTTP本身是一种无状态的协议

这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,认证通过后HTTP协议不会记录下认证后的状态,那么下一次请求时,用户还要再一次进行认证。

因为根据HTTP协议,我们并不知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在用户首次登录成功后,在服务器存储一份用户登录的信息。

这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了。

这是传统的基于session认证的过程

然而,传统的session认证有如下的问题:

  • 每个用户的登录信息都会保存到服务器的session中,随着用户的增多,服务器开销会明显增大
  • 由于session是存在与服务器的物理内存中,所以在分布式系统中,这种方式将会失效。虽然可以将session统一保存到Redis中,但是这样做无疑增加了系统的复杂性,对于不需要redis的应用也会白白多引入一个缓存中间件
  • 对于非浏览器的客户端、手机移动端等不适用,因为 session 依赖于 cookie,而移动端经常没有 cookie
  • 因为 session 认证本质基于 cookie ,所以如果 cookie 被截获,用户很容易收到跨站请求伪造攻击。并且如果浏览器禁用了 cookie,这种方式也会失效
  • 前后端分离系统中更加不适用,后端部署复杂,前端发送的请求往往经过多个中间件到达后端,cookie 中关于 session 的信息会转发多次
  • 由于基于Cookie,而cookie无法跨域,所以session的认证也无法跨域,对单点登录不适用

JWT认证的优势

对比传统的session认证方式,JWT的优势是:

  • 简洁:JWT Token数据量小,传输速度也很快
  • 因为JWT Token是以JSON加密形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持
  • 不需要在服务端保存会话信息,也就是说不依赖于cookie和session,所以没有了传统session认证的弊端,特别适用于分布式微服务
  • 单点登录友好:使用Session进行身份认证的话,由于cookie无法跨域,难以实现单点登录。但是,使用token进行认证的话, token可以被保存在客户端的任意位置的内存中,不一定是cookie,所以不依赖cookie,不会存在这些问题
  • 适合移动端应用:使用Session进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到Cookie(需要 Cookie 保存 SessionId),所以不适合移动端

因为这些优势,目前无论单体应用还是分布式应用,都更加推荐用JWT token的方式进行用户认证

JWT结构

在其紧凑形式中,JWT 由点 . 分割的三个部分组成,分别是:

  • 标头(Header)
  • 有效载荷(Payload)
  • 签名(Signature)

因此,一个 JWT 通常如下所示: xxxxx.yyyyy.zzzzz

具体组成

JWTString=Base64(Header).Base64(Payload).HMACSHA256(base64UrlEncode(header)+'.'+base64UrlEncode(payload),密钥)

标头(Header)

JWT头是一个描述JWT元数据的JSON对象,alg属性表示签名使用的算法,默认为HMAC SHA256(写为HS256);

typ属性表示令牌的类型,JWT令牌统一写为JWT。

最后,使用Base64 URL算法将上述JSON对象转换为字符串保存

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

有效载荷(Payload)

有效载荷部分,是JWT的主体内容部分,也是一个JSON对象,包含需要传递的数据。

它包含声明,声明是关于实体(通常是用户)和附加数据的陈述。

声明分为三种类型: 注册声明、公共声明和私有声明。

  1. 注册声明: 这些是一组预定义的声明,具有特定的含义并进行了标准化, JWT指定七个标准字段供选择:

    iss:发行人
    exp:到期时间
    sub:主题
    aud:用户
    nbf:在此之前不可用
    iat:发布时间
    jti:JWT ID用于标识该JWT
    
  2. 公共声明: 这些是由 jwt 的使用者定义的自定义声明,它们没有标准化。公共声明应该以避免与其他声明冲突的方式进行定义。这些声明用于携带关于实体的附加信息或提供特定域应用程序的细节。

    例如:

    {
       "name": "Helen",
       "admin": true,
       "userid": 1
    }
    
  3. 私有声明: 这些是由共享 JWT 的各方之间的私有协议定义和使用的自定义声明。它们不适用于其他方使用或理解;


一个有效的 payload 的例子是:

{
  "exp": 1701390069,
  "iss": "somebody",
  "name": "Helen",
  "admin": true,
  "userid": 1
}

请注意,默认情况下JWT是未加密的,因为只是采用base64算法,拿到JWT字符串后可以转换回原本的JSON数据,任何人都可以解读其内容,因此不要构建隐私信息字段,比如用户的密码一定不能保存到JWT中,以防止信息泄露。JWT只是适合在网络中传输一些非敏感的信息

签名(Signature)

签名哈希部分是对上面两部分数据签名,需要使用base64编码后的header和payload数据,通过指定的算法生成哈希,以确保数据不会被篡改。

首先,需要指定一个密钥(secret)。

该密码仅仅为保存在服务器中,并且不能向用户公开。

然后,使用header中指定的签名算法(默认情况下为HMAC SHA256)根据以下公式生成签名

HMACSHA256(base64UrlEncode(header)+'.'+base64UrlEncode(payload),密钥)

在计算出签名哈希后,JWT头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用 . 分隔,就构成整个JWT对象


注意JWT每部分的作用,在服务端接收到客户端发送过来的JWT token之后:

  • headerpayload 可以直接利用 base64 解码出原文,从 header 中获取哈希签名的算法,从 payload 中获取有效数据
  • signature 由于使用了不可逆的加密算法,无法解码出原文,它的作用是校验token有没有被篡改。
    服务端获取header中的加密算法之后,利用该算法加上 secretKeyheaderpayload 进行加密,比对加密后的数据和客户端发送过来的是否一致。
    注意 secretKey 只能保存在服务端,而且对于不同的加密算法其含义有所不同,一般对于 MD5 类型的摘要加密算法,secretKey 实际上代表的是盐值

JWT的种类

其实JWT(JSON Web Token)指的是一种规范,这种规范允许我们使用JWT在两个组织之间传递安全可靠的信息,JWT的具体实现可以分为以下几种:

  • nonsecure JWT:未经过签名,不安全的JWT
  • JWS:经过签名的JWT
  • JWE:payload部分经过加密的JWT

nonsecure JWT

未经过签名,不安全的JWT。其 header 部分没有指定签名算法

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

并且也没有 Signature 部分

JWS

JWS ,也就是JWT Signature,其结构就是在之前nonsecure JWT的基础上,在头部声明签名算法,并在最后添加上签名。

创建签名,是保证jwt不能被他人随意篡改。我们通常使用的JWT一般都是JWS

为了完成签名,除了用到header信息和payload信息外,还需要算法的密钥,也就是secretKey。

加密的算法一般有2类:

  • 对称加密:secretKey指加密密钥,可以生成签名与验签
  • 非对称加密:secretKey指私钥,只用来生成签名,不能用来验签(验签用的是公钥)

JWT的密钥或者密钥对,一般统一称为JSON Web Key,也就是JWK

到目前为止,jwt的签名算法有三种:

  • HMAC【哈希消息验证码(对称)】:HS256/HS384/HS512
  • RSASSA【RSA签名算法(非对称)】(RS256/RS384/RS512)
  • ECDSA【椭圆曲线数据签名算法(非对称)】(ES256/ES384/ES512)

GO中的简单应用

安装 JWT 库

在 Go 项目中使用 JWT 需要先安装 JWT 库,可以使用以下命令安装:

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

将其导入代码中:

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

创建JWT

package main

import (
	"errors"
	"fmt"
	"strings"
	"time"

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

// CustomClaims 自定义声明类型 并内嵌jwt.RegisteredClaims
// jwt包自带的jwt.RegisteredClaims只包含了官方字段
// 假设我们这里需要额外记录一个username字段,所以要自定义结构体
// 如果想要保存更多信息,都可以添加到这个结构体中
type CustomClaims struct {
	// 可根据需要自行添加字段
	UserID               int64  `json:"user_id"`
	Username             string `json:"username"`
	jwt.RegisteredClaims        // 内嵌标准的声明
}


// 密钥
var secretKey = "abcdef"

// GenToken 生成JWT
func GenToken(userId int64, username string) (string, error) {
	// 创建一个我们自己声明的数据
	claims := CustomClaims{
		userId,
		username, // 自定义字段
		jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)), // 定义过期时间
			Issuer:    "somebody",                                         // 签发人
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	// 生成签名字符串
	return token.SignedString([]byte(secretKey))
}

func main() {
	res, err := GenToken(123, "abc")
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Println(res)
	}
}

在上面的例子中,使用 jwt.MapClaims 定义了需要存储在 JWT 中的数据,使用 jwt.NewWithClaims() 方法创建了 JWT,然后使用 SignedString() 方法生成了签名字符串。

运行后的结果

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWJjIiwiaXNzIjoic29tZWJvZHkiLCJleHAiOjE3MDE1MDk1Mjh9.rJiXzq3z8MwmSLHndn08XlDJAkWcy_1w1Qw4WcgEdMA

验证JWT

在 Go 项目中,可以使用 JWT 库的 jwt.Parse() 方法验证 JWT

例如:

package main

import (
	"errors"
	"fmt"
	"strings"
	"time"

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

// CustomClaims 自定义声明类型 并内嵌jwt.RegisteredClaims
// jwt包自带的jwt.RegisteredClaims只包含了官方字段
// 假设我们这里需要额外记录一个username字段,所以要自定义结构体
// 如果想要保存更多信息,都可以添加到这个结构体中
type CustomClaims struct {
	// 可根据需要自行添加字段
	UserID               int64  `json:"user_id"`
	Username             string `json:"username"`
	jwt.RegisteredClaims        // 内嵌标准的声明
}


// 密钥
var secretKey = "abcdef"

func ParseToken(tokenString string) (*CustomClaims, error) {
	// 解析token
	var mc = new(CustomClaims)
	token, err := jwt.ParseWithClaims(tokenString, mc, func(token *jwt.Token) (i interface{}, err error) {
		return []byte(secretKey), nil
	})
	if err != nil {
		if strings.Contains(err.Error(), "could not JSON decode header") {
			fmt.Println("token可能被篡改,头解析失败")
		}
		if err.Error() == "token signature is invalid: signature is invalid" {
			fmt.Println("令牌签名无效")
		}
		if err.Error() == "token has invalid claims: token is expired" {
			fmt.Println("令牌已过期")
		}
		return nil, err
	}
	// 对token对象中的Claim进行类型断言
	if token.Valid { // 校验token
		fmt.Println("令牌有效")
		return mc, nil
	}
	return nil, errors.New("无效的令牌")
}
func main() {
	token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWJjIiwiaXNzIjoic29tZWJvZHkiLCJleHAiOjE3MDE1MDk1Mjh9.rJiXzq3z8MwmSLHndn08XlDJAkWcy_1w1Qw4WcgEdMA"
	res, err := ParseToken(token)
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Println(res)
	}
}

运行的结果

令牌有效
&{123 abc {somebody  [] 2023-12-02 17:32:08 +0800 CST <nil> <nil> }}

在上面的例子中,使用 jwt.Parse() 方法解析 JWT,并在回调函数中检查签名算法和返回签名字符串。然后检查 token 是否有效,并从中提取出需要的数据。

以上就是在 Go 项目中使用 JWT 的基本步骤。需要注意的是,在实际应用中,需要更加严格地设置 JWT 的有效期、密钥等参数,以确保安全性。

在线解析的工具

  1. JSON Web Tokens (JWT) 在线解密, 开发工具箱 - JWT 在线解密 。这个网页提供了jwt解密功能。功能比较简单,上手很容易。

  2. JWT parser, IT Tools - Handy online tools for developers 。这个网页也提供了jwt解密功能,比较上一个网页,这个网页的解密结果里会把payload里面的unix时间戳转成我们能看懂的时间格式。

  3. ToolTT在线加解密, JWT Token在线解析解码 - ToolTT在线工具箱JWT Token在线编码生成 - ToolTT在线工具箱 。这个网站的体验又比前两个网页的体验要好。额外支持jwt令牌的生成,令牌生成的选项比较细致,可读性墙方便操作。美中不足,不允许修改令牌的签发时间。

  4. bejson在线工具, jwt解密/加密 - bejson在线工具 。如果您正在寻找一种可靠且高效的处理JWT的方式,它无疑是一个很好的选择。这个网页除了提供了全面的JWT加密和解密功能外,还提供了签名验证功能

问题

如果只修改 payload 不修改头部和签名会怎样

头部和签名通常是不可更改的,因为签名是基于头部、载荷和密钥计算得出的结果,用于验证 token 的完整性和真实性。如果头部和签名被更改,验证过程会失败,因此头部和签名部分通常需要保持不变。
如果 JWT token 中的头部和签名不作修改,但是修改了载荷部分,那么验证 token 的过程仍然会成功,因为签名仍然与原始载荷匹配。这可能会导致潜在的安全问题,因为后端是基于 token 中的载荷信息去做出一些决策或者判断的,类似于要根据你的id去查找相对应的用户信息。而恶意修改载荷可能会导致不正确的行为或授权问题。

总结

  • 由于 HeaderPayload 是基本使用 Base64URL 能进行编码解码的,所以 HeaderPayload 的内容是等同于公开的,谁拿到 JWT,都能知道内容;
  • 由于 Signature 的生成方式,JWT 一旦生成,没有 Secret 的人是没法篡改 payload 的。因为改了 payload 后,你需要 secret 对新的 payload 进行签名;

综上所述,jwt 一经发出,就无法修改了。一旦泄漏,任何人都可以获得该 token 的所有权限。为了减少盗用,jwt 的有效期应该设置得比较短,对于一些比较重要的权限,使用时应该再次对用户进行认证。