Gin中使用jwt-go实现JWT鉴权登陆

背景

1、传统的session认证 

我们知道,http协议本身是一种无状态的协议,而这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证。用户认证成功后,服务器开辟空间存储当前用户信息(session),而发给客户端的 sesssion_id 存放到 cookie 中,这样用客户端请求时带上 session_id 就可以验证服务器端是否存在session 数据,以此完成用户的合法校验。

Gin中使用jwt-go实现JWT鉴权登陆_第1张图片

但是这种基于session的认证存在很多弊端,例如:

  • 资源开销: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
  • 扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
  • 安全性: 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的CSRF攻击。
     

2、基于token的鉴权机制

基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。

Gin中使用jwt-go实现JWT鉴权登陆_第2张图片

  1. 用户使用用户名密码来请求服务器
  2. 服务器进行验证用户的信息
  3. 服务器通过验证发送给用户一个token
  4. 客户端存储token,并在每次请求时附送上这个token
  5. 服务端验证token,并返回数据 

3、JWT概述

JWT全称Json web token ,是一种用于通信双方之间传递安全信息的简洁的、URL安全的表述性声明规范,是目前最流行的跨域身份验证解决方案。

JWT基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息,一旦用户完成了登陆,在接下来的每个请求中都会包含JWT,可以用来验证用户身份以及对路由,服务和资源的访问权限的验证。由于它的开销非常小,可以轻松的在不同域名的系统中传递,这也为应用的扩展提供了便利。

JWT 以 JSON 对象的形式安全传递信息。因为存在数字签名,因此所传递的信息是安全的。以下是JWT官网对JWT的一段介绍描述。

JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.

JWT原理

JWT 的构成

JWT令牌由三个部分组成,由.分隔,分别是:

  1. header:令牌头部,记录了整个令牌的类型和签名算法
  2. payload:令牌负荷,记录了保存的主体信息,比如你要保存的用户信息就可以放到这里
  3. signature:令牌签名,按照头部固定的签名算法对整个令牌进行签名,该签名的作用是:保证令牌不被伪造和篡改

因此,JWT 通常具有如下所示的结构:xxxxx.yyyyy.zzzzz

JWT header

header通常由两部分组成:令牌的类型(即 JWT)和正在使用的签名算法,例如 HMAC、SHA256 或 RSA。

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

设置好了header的结构之后,还需要对header的JSON对象进行Base64 Url编码,最后编码后生成的字符串才是最终的header部分。

JWT payload

令牌的第二部分是有效负载,其中包含有三种类型的声明:标准中注册的声明、公共声明和私人声明。声明是有关实体(通常是用户)和其他数据的语句。

标准注册声明 (建议但不强制使用) :

  • iss: jwt签发者
  • sub: jwt所面向的用户
  • aud: 接收jwt的一方
  • exp: jwt的过期时间,这个过期时间必须要大于签发时间
  • nbf: 定义在什么时间之前,该jwt都是不可用的.
  • iat: jwt的签发时间
  • jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
     

公共的声明:

这些可以由使用 JWT 的人随意定义。但为了避免冲突,它们应该在 IANA JSON Web 令牌注册表中定义,或者定义为包含抗冲突命名空间的 URI。

私有的声明:

是提供者和消费者所共同定义的声明,用于在同意使用它们的各方之间共享信息。

以上所有的声明中一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
 

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

 playload部分和header一样,需要通过Base64编码。

JWT Signature

签名用于验证消息在此过程中未被更改,并且在使用私钥签名的令牌还可以验证 JWT 的发件人是否是它所说的人。

要创建签名部分,您必须获取编码的header、有效负载、秘钥、header中指定的算法并对其进行签名。例如,如果要使用 HMAC SHA256 算法,将按以下方式创建签名:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串, 然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。

完整的JWT 

最后的输出是三个由点分隔的 Base64-URL 字符串,可以在 HTML 和 HTTP 环境中轻松传递,与基于 XML 的标准(如 SAML)相比更加紧凑。

下图是对header和payload进行了编码并使用秘钥进行签名的 JWT。

Gin中使用jwt-go实现JWT鉴权登陆_第3张图片

可以使用调试器 jwt.io 解码、验证和生成 JWT。 

Gin中使用jwt-go实现JWT鉴权登陆_第4张图片

JWT的优点

1、无状态

JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。

2、有效避免了 CSRF 攻击

CSRF 攻击需要依赖 Cookie。一般情况下我们使用 JWT 的话,在我们登录成功获得 JWT 之后,一般会选择存放在 localStorage 中。前端的每一个请求后续都会附带上这个 JWT,整个过程不会涉及到 Cookie,因此可以避免 CSRF 攻击。

3、适合移动端应用 

使用 Session 进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到 Cookie,当你的客户端是一个原生平台(iOS, Android,Windows 8等)时,Cookie是不被支持的。但是,使用 JWT 进行身份认证就不会存在这种问题,因为只要 JWT 可以被客户端存储就能够使用,而且 JWT 还可以跨语言使用。

4、性能开销小

 一次网络往返时间(通过数据库查询session信息)总比做一次HMACSHA256计算的Token验证和解析要费时得多。

JWT 身份认证常见问题

注销问题

传统的 session+cookie 方案用户点击注销,服务端清空 session 即可,因为状态保存在服务端。但是因为 jwt 是无状态的,服务端通过计算来校验有效性,所以即使客户端删除了 jwt,但是该 jwt 还是在有效期内,只不过处于一个游离状态。jwt 的无状态使得其注销变得复杂。解决方案有如下几种:

1、将 JWT 存入内存数据库

将 JWT 存入 DB 中,Redis 内存数据库在这里是不错的选择。如果需要让某个 JWT 失效就直接从 Redis 中删除这个 JWT 即可。但是,这样会违背了 JWT 的无状态原则。

2、黑名单机制

使用内存数据库比如 Redis 维护一个黑名单,如果想让某个 JWT 失效的话就直接将这个 JWT 加入到黑名单即可。然后,每次使用 JWT 进行请求的话都会先判断这个 JWT 是否存在于黑名单中。这样做同样会违背了 JWT 的无状态原则。

3、修改密钥 (Secret) 

我们为每个用户都创建一个专属密钥,如果我们想让某个 JWT 失效,我们直接修改对应用户的密钥即可。

4、保持令牌的有效期限短并经常轮换

很简单的一种方式。但是,会导致用户登录状态不会被持久记录,而且需要用户经常登录。

续签问题

传统的 cookie 续签方案一般都是框架自带的,session 有效期 30 分钟,30 分钟内如果有访问,session 有效期被刷新至 30 分钟。而 jwt 本身的 payload 之中也有一个 exp 过期时间参数,来代表一个 jwt 的时效性,但是因为 payload 是参与签名的,一旦这个过期时间被修改,整个 jwt 串就变了,jwt 的特性天然不支持续签。解决方案有如下几种:

1、每次请求刷新 jwt

jwt 修改 payload 中的 exp 后整个 jwt 串就会发生改变,因此每次请求都返回一个新的 jwt 给客户端。这种方案的的优点是思路很简单,但是,开销会比较大,尤其是在服务端要存储维护 JWT 的情况下。

2、刷新快要过期的jwt

服务端每次进行校验时,如果发现 JWT 的有效期马上快过期了,服务端就重新生成 JWT 给客户端。客户端每次请求都检查新旧 JWT,如果不一致,则更新本地的 JWT。这种做法的问题是对客户端不是很友好,有可能会刚好错过刷新时机。

3使用Refresh Token刷新

服务端不需要刷新 Token 的过期时间,一旦 Token 过期,就反馈给前端,前端使用 Refresh Token 申请一个全新 Token 继续使用。这种方案中,服务端只需要在客户端请求更新 Token 的时候对 Refresh Token 的有效性进行一次检查,大大减少了更新有效期的操作,也就避免了频繁读写。当然 Refresh Token 也是有有效期的,但是这个有效期就可以长一点了,比如,以天为单位的时间。
 

Gin之使用JWT

在Go语言中,JWT(JSON Web Token)鉴权可以使用第三方库来实现,比如jwt-go。

库的介绍和使用可见文档:jwt package - github.com/golang-jwt/jwt/v5 - Go Packages

创建JWT令牌

在服务器中,我们可以使用以下代码创建JWT令牌

package middleware

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"mybili/serializer"
	"mybili/utils"
	"net/http"
	"os"
	"time"
)

type MyClaims struct {
	Username string `json:"user_name"`
	//Password string `json:"password"`
	jwt.RegisteredClaims
}

// 生成token
func SetToken(username string) (string, int) {
	SetClaims := MyClaims{
		Username: username,
		//Password: password,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), //有效时间
			IssuedAt:  jwt.NewNumericDate(time.Now()),                     //签发时间
			NotBefore: jwt.NewNumericDate(time.Now()),                     //生效时间
			Issuer:    os.Getenv("JWT_ISSUER"),                            //签发人
			Subject:   "somebody",                                         //主题
			ID:        "1",                                                //JWT ID用于标识该JWT
			Audience:  []string{"somebody_else"},                          //用户
		},
	}

	//使用指定的加密方式和声明类型创建新令牌
	tokenStruct := jwt.NewWithClaims(jwt.SigningMethodHS256, SetClaims)
	//获得完整的、签名的令牌
	token, err := tokenStruct.SignedString([]byte(os.Getenv("JWT_KEY")))
	if err != nil {
		utils.Logger.Errorf("err:%v", err.Error())
		return "", utils.TOKEN_CREATE_FAILED
	}
	return token, utils.SUCCESS
}

jwt.RegisteredClaims是对标准注册声明的封装,您可以单独使用它,也可以把它嵌入到自定义类型中,以提供标准验证功能。

jwt.NewWithClaims使用指定的签名方法和声明创建一个新令牌。

创建了一个名为MyClaims 的结构体,用于定义JWT负载。除了标准验证选项外,我们也可以自定义Payload有效载荷字段,例如将用户名Username放到payload中。

JWT验证

验证JWT可以使用如下代码实现:

// 验证token
func CheckToken(token string) (*MyClaims, int) {
	//解析、验证并返回token。
	tokenObj, err := jwt.ParseWithClaims(token, &MyClaims{}, func(token *jwt.Token) (interface{}, error) {
		return []byte(os.Getenv("JWT_KEY")), nil
	})

	if err != nil {
		utils.Logger.Errorf("err:%v", err.Error())
		return nil, utils.ERROR
	}

	if claims, ok := tokenObj.Claims.(*MyClaims); ok && tokenObj.Valid {
		fmt.Printf("%v %v\n", claims.Username, claims.RegisteredClaims)
		return claims, utils.SUCCESS
	} else {
		return nil, utils.ERROR
	}
}

jwt.ParseWithClaims是NewParser().ParseWithClaims()的快捷方式,第一个参数是token的string值,第二个参数是我们之后需要把解析的数据放入的地方,第三个参数将被Parse方法用作回调函数,以提供用于验证的键。函数接收已解析但未验证的令牌。

解析结果输出结果如下: 

Username:mjiarong
RegisteredClaims:{mybili somebody [somebody_else] 2023-10-02 20:25:30 +0800 CST 2023-10-01 20:25:30 +0800 CST 2023-10-01 20:25:30 +0800 CST 1}

JWT中间件

在中间件中服务端会获取用户token,基于jwt校验是否合法,合法放行,否则拒绝。

// jwt中间件
func JwtToken() gin.HandlerFunc {
	return func(c *gin.Context) {
		tokenHeader := c.Request.Header.Get("Authorization")
		code := utils.SUCCESS
		if tokenHeader == "" {
			code = utils.TOKEN_NOT_EXIST
			c.JSON(http.StatusOK, serializer.CheckToken(
				code,
				utils.GetErrMsg(code)))
			c.Abort()
			return
		}

		key, tCode := CheckToken(tokenHeader)
		if tCode == utils.ERROR {
			code = utils.TOKEN_WRONG
			c.JSON(http.StatusOK, serializer.CheckToken(
				code,
				utils.GetErrMsg(code)))
			c.Abort()
			return
		}

		//判断token是否过期
		if time.Now().Unix() > key.ExpiresAt.Unix() {
			code = utils.TOKEN_RUNTIME
			c.JSON(http.StatusOK, serializer.CheckToken(
				code,
				utils.GetErrMsg(code)))
			c.Abort()
			return
		}

		c.Set("username", key.Username)
		c.Next()
	}
}

前端JWT登录鉴权的实现 

一般情况下,客户端在登录后得到了一个JWT方式的token。那这个token放哪里呢?最好把JWT放在HTTP请求的Header Authorization,格式是Authorization: Bearer jwtStr。那是否意味着前端的每个请求都附带后端返回的token吗?显然是否定的,我们只需要把需要鉴权登陆的请求头中带上token就行了。对此我们可以使用axios的请求拦截器来对请求进行过滤,以下是作者自己项目中的一个请求拦截器的实现:

import axios from "axios";

let http = axios.create({
	baseURL: process.env.VUE_APP_BASE_API, //配置默认的地址
	withCredentials: true, //将会默认携带认证给后端
	timeout: 1000 * 10, //请求超时设置,如果超过了10秒,那么就会进入reject
});

http.interceptors.request.use(
	//axios的请求拦截器,它可以拦截所有的请求,为所有的请求添加逻辑
	//拦截了请求后,如果不放行,那么所有的请求会一直被拦截,因此需要return不需要拦截的请求。
	function(config) {
		let postWhiteList = [
			"/user/login",
			"/user/register",
		]; //将不需要拦截的请求拿出来
		let getWhiteList = [
			"/rank/daily",
			"/comment",
			"/upload/token",
			"/upload/credentials",
			"/comment",
		];

		if (config.method === 'post' && postWhiteList.includes(config.url)) {
			//如果当前的请求地址中,包含在不需要拦截请求地址中,那么就放行
			return config;
		} else if (config.method === 'get' && (getWhiteList.includes(config.url) || config.url.includes("/video"))) {
			return config;
		} else {
			//如果是需要被拦截的请求
			let token = window.sessionStorage.getItem("token") || ""; //将登录成功后,在后端中返回来的token从本地存储取出来
			config.headers["Authorization"] = token; //给需要拦截的请求中请求头添加token。config.headers["authorization"]是一个固定的写法
			return config; //添加后,放行
		}
	}
);

对此,我们前后端中对JWT的实现就基本完成了。

总结 

JWT作为一种安全、简单的认证方式,被广泛应用于Web开发中。本文介绍了如何在Go语言中使用jwt-go库来快速、简单地实现JWT功能。JWT不仅可以用于用户认证,还可以用于数据传输、API认证等场景。在实际应用中,我们应该注意JWT的有效期,以及签名密钥的安全保护。任何技术都不是完美的,JWT 也有很多缺陷,具体是选择 JWT 还是 Session 方案还是要看项目的具体需求。

你可能感兴趣的:(gin,golang,后端,javascript,web安全)