在Gin中使用JWT做认证以及JWT的续签方案
1 需求
之前做后台都是用的Session机制来做认证,最近在用Gin重构之前SpringBoot项目,网上看了几个开源Gin项目发现都是用的JWT来做认证,所以打算尝试一下在Gin中使用JWT做认证。Token机制相对于Session机制来说有许多优点,不需要服务端存储数据可以减小服务端的开销;但Token也有一些问题,在签发后服务端就不能改变其状态,不能主动让其失效、不能延长有效期。这里记录一下Gin中JWT的基本使用、以及一个简单的续签方案。 本文的完整demo结构如下(完整示例代码见Github),middleware中拦截请求验证Token有效性,utils中对jwt的生成、续签和验证做封装:
|——gin_jwt
| |——middleware
| |——jwt.go
| |——model
| |——user.go
| |——utils
| |——jwt.go
| |——go.mod
| |——main.go
2 Gin中使用JWT
看了几个开源Gin项目和一些博客都使用的是dgrijalva/jwt-go这个jwt库,但是这个库4年前就停止维护了,目前相关人员维护了一个新的jwt库golang-jwt/jwt,这里使用这个新的jwt库。首先使用如下命令下载jwt库:
go get -u github.com/golang-jwt/jwt/v4
假设我需要在Token中保存用户ID、用户名和Email,自定义如下Claims结构体:
type UserInfo struct {
Id int
UserName string
Email string
}
type MyClaims struct {
User model.UserInfo
jwt.StandardClaims // 标准Claims结构体,可设置8个标准字段
}
2.1 生成Token
生成标准claim时过期时间一般不用太长,这里设置的两个小时后过期。生成Token时调用jwt库的NewWithClaims即可,传入签名算法和claims结构体,签名算法用得最多的是HS256。
const TokenExpireDuration = time.Hour * 2
var MySecret = []byte("yoursecret") // 生成签名的密钥
// 登录成功后调用,传入UserInfo结构体
func GenerateToken(userInfo model.UserInfo) (string, error) {
expirationTime := time.Now().Add(TokenExpireDuration) // 两个小时有效期
claims := &MyClaims{
User: userInfo,
StandardClaims: jwt.StandardClaims{
ExpiresAt: expirationTime.Unix(),
Issuer: "yourname",
},
}
// 生成Token,指定签名算法和claims
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// 签名
if tokenString, err := token.SignedString(MySecret); err != nil {
return "", err
} else {
return tokenString, nil
}
}
2.2 校验Token
将前端传来的token字符串传入解析校验函数,校验函数调用ParseWithClaims进行解析,此时有两种解析方法,一种是将解析结果保存到claims变量中,另一种是从ParseWithClaims返回的Token结构体中取出Claims结构体。这里选择第一种,若token字符串合法但过期claims也会有数据,err会提示token过期。
func ParseToken(tokenString string) (*MyClaims, error) {
claims := &MyClaims{}
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
return MySecret, nil
})
// 若token只是过期claims是有数据的,若token无法解析claims无数据
return claims, err
}
// 第二种方法通过jwt.ParseWithClaims返回的Token结构体取出Claims结构体
func ParseToken2(tokenString string) (*MyClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(t *jwt.Token) (interface{}, error) {
return MySecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("token无法解析")
}
2.3 中间件拦截请求并校验Token
这里的中间件逻辑没有续签功能,当Token校验失败,无论是过期还是不合法直接拒绝用户请求。
func JWTAuth() gin.HandlerFunc {
return func(context *gin.Context) {
auth := context.Request.Header.Get("Authorization")
if len(auth) == 0 {
context.Abort()
context.String(http.StatusOK, "未登录无权限")
return
}
// 校验token,只要出错直接拒绝请求
_, err := utils.ParseToken(auth)
if err != nil {
context.Abort()
message := err.Error()
context.JSON(http.StatusOK, message)
return
} else {
println("token 正确")
}
context.Next()
}
}
2.4 主函数逻辑
这里简单编写了一下主函数,只有两个接口:一个是登录接口,另一个是需要验证Token的sayHello接口,若用户登录了此接口会返回Hello + 用户名。
package main
import (
"gin_jwt/middleware"
"gin_jwt/model"
"gin_jwt/utils"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// 不访问数据,写死用户数据
var db = &model.User{Id: 10001, Email: "abc@gmail.xyz", UserName: "Alice", Password: "123456"}
func setupRouter() *gin.Engine {
r := gin.Default()
r.POST("login", func(c *gin.Context) {
var userVo model.User
if c.ShouldBindJSON(&userVo) != nil {
c.String(http.StatusOK, "参数错误")
return
}
if userVo.Email == db.Email && userVo.Password == db.Password {
info := model.NewInfo(*db)
tokenString, _ := utils.GenerateToken(*info)
c.JSON(http.StatusOK, gin.H{
"code": 201,
"token": tokenString,
"msg": "登录成功",
})
return
}
c.String(http.StatusOK, "登录失败")
return
})
authorized := r.Group("/", middleware.JWTAuth())
authorized.GET("/sayHello", func(c *gin.Context) {
auth := c.Request.Header.Get("Authorization")
claims, _ := utils.ParseToken(auth)
log.Println(claims)
c.String(http.StatusOK, "hello "+claims.User.UserName)
})
return r
}
func main() {
r := setupRouter()
r.Run(":8080")
}
3 续签Token
按照JWT标准,Token就应该是无状态的,过期后重新給客户端发布新Token即可。但Token过期时间一般比较短,若没有自动续签机制的话,让用户频繁重新登录会造成比较糟糕的体验。目前网上提的比较多的方案是结合redis维持Token黑名单或白名单,原理和session有点类似了,服务端保存了Token的状态有点违背了Token的无状态原则;还有的方案是使用两个Token,一个access_token用于访问资源,一个过期时间较长的refresh_token用于获取新的access_token。这里想了一个比较简单的方案,当Token过期但不超过10分钟时放行请求并为其生成一个新的Token,将新token放在一个名为newtoken的自定义header中;前端拦截器中判断response的header是否有newtoken,若有的话则刷新本地保存的token字符串。
3.1 封装续签函数
判断claims的过期时间是否超过指定时间,若还未超过指定时间则生成一个新的token字符串,否则返回空字符串。
func RenewToken(claims *MyClaims) (string, error) {
// 若token过期不超过10分钟则给它续签
if withinLimit(claims.ExpiresAt, 600) {
return GenerateToken(claims.User)
}
return "", errors.New("登录已过期")
}
// 计算过期时间是否超过l
func withinLimit(s int64, l int64) bool {
e := time.Now().Unix()
return e-s < l
}
3.2 修改jwt的中间件逻辑
当Token校验失败时判断一下是否是过期错误,若是过期错误则将Claims结构体传给续签函数,若续签成功则将新的token字符串放到response的指定header中,并修改request的header值。
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.Request.Header.Get("Authorization")
if len(auth) == 0 {
// 无token直接拒绝
c.Abort()
c.String(http.StatusOK, "未登录无权限")
return
}
// 校验token
claims, err := utils.ParseToken(auth)
if err != nil {
if strings.Contains(err.Error(), "expired") {
// 若过期,调用续签函数
newToken, _ := utils.RenewToken(claims)
if newToken != "" {
// 续签成功給返回头设置一个newtoken字段
c.Header("newtoken", newToken)
c.Request.Header.Set("Authorization", newToken)
c.Next()
return
}
}
// Token验证失败或续签失败直接拒绝请求
c.Abort()
c.String(http.StatusOK, err.Error())
return
}
// token未过期继续执行1其他中间件
c.Next()
}
}
3.3 前端添加拦截器
这里以Vue常用的axios为例,在response的拦截器中判断是否有自定义的header,若有则从中取出新的token字符串用以刷新本地旧的token字符串。
axios.interceptors.response.use(
res => {
let newToken = res.headers["newtoken"]
if (res.headers["newtoken"]) {
// 若有newtoken则刷新token
sessionStorage.setItem('tokenString', newToken)
console.log('token refreshed~~~')
}
return res
},
err =>{
return Promise.reject(err)
})
4 测试运行
这里使用Postman进行测试,首先发起登录请求,登录成功后得到服务端返回的token字符串。
在header的Authorization中加入token字符串访问sayHello接口可以得到响应
Token过期10分钟内访问sayHello接口,可以看到依然有响应,并且response的header中多了newtoken字段,编写前端时保存这个新的token即可。
转载自:https://juejin.cn/post/7059184806906560543