文章目录

上一个Demo中,token一旦过期无法刷新需要重新登录,因此需要某种机制来自动更新token

一: 实现原理

1. 后端中间件改进

读取token中过期时间
token过期时间-最大截止刷新时间>当前时间?
返回结果添加一个http头new-token=新生成的token
End
//gin jwt 认证中间件
func AuthRequired() gin.HandlerFunc {
	return func(ctx *gin.Context) {
		tokenString := strings.TrimPrefix(ctx.GetHeader("Authorization"), "Bearer ")
		token, err := jwt.ParseWithClaims(tokenString, &customClaims{}, func(t *jwt.Token) (interface{}, error) { return jwtKey, nil })
		if err != nil {
			ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": -1, "msg": fmt.Sprintf("access token parse error: %v", err)})
			return
		}
		if claims, ok := token.Claims.(*customClaims); ok && token.Valid {
			if !claims.VerifyExpiresAt(time.Now(), false) {
				ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": -1, "msg": "access token expired"})
				return
			}
			// *******************************新增部分***********************************
			// 即将超过过期时间,则添加一个http header `new-token` 给前端更新
			if t := claims.ExpiresAt.Time.Add(-time.Minute * TOKEN_MAX_REMAINING_MINUTE); t.Before(time.Now()) {
				claims := customClaims{
					Username: claims.Username,
					IsAdmin:  claims.Username == "admin",
					RegisteredClaims: jwt.RegisteredClaims{
						ExpiresAt: &jwt.NumericDate{Time: time.Now().Add(TOKEN_MAX_EXPIRE_HOUR * time.Hour)},
					},
				}
			// *******************************新增部分结束*******************************
				token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
				tokenString, _ := token.SignedString(jwtKey)
				ctx.Header("new-token", tokenString)
			}
			ctx.Set("claims", claims)
		} else {
			ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": -1, "msg": fmt.Sprintf("Claims parse error: %v", err)})
			return
		}
		ctx.Next()
	}
}

2. 前端改进

请求API
返回http头是否包含new-token?
更新localStorage中token
End

3. 过期后点击请求测试

在这里插入图片描述

二: 完整代码

后端

package main

import (
	"fmt"
	"log"
	"net/http"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v4"
)

var jwtKey []byte = []byte("secret")

const (
	TOKEN_MAX_EXPIRE_HOUR      = 1  // token最长有效期
	TOKEN_MAX_REMAINING_MINUTE = 15 // token还有多久过期就返回新token
)

type customClaims struct {
	Username string `json:"username"`
	IsAdmin  bool   `json:"IsAdmin"`
	jwt.RegisteredClaims
}

//gin jwt 认证中间件
func AuthRequired() gin.HandlerFunc {
	return func(ctx *gin.Context) {
		tokenString := strings.TrimPrefix(ctx.GetHeader("Authorization"), "Bearer ")
		token, err := jwt.ParseWithClaims(tokenString, &customClaims{}, func(t *jwt.Token) (interface{}, error) { return jwtKey, nil })
		if err != nil {
			ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": -1, "msg": fmt.Sprintf("access token parse error: %v", err)})
			return
		}
		if claims, ok := token.Claims.(*customClaims); ok && token.Valid {
			if !claims.VerifyExpiresAt(time.Now(), false) {
				ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": -1, "msg": "access token expired"})
				return
			}
			// 即将超过过期时间,则添加一个http header `new-token` 给前端更新
			if t := claims.ExpiresAt.Time.Add(-time.Minute * TOKEN_MAX_REMAINING_MINUTE); t.Before(time.Now()) {
				claims := customClaims{
					Username: claims.Username,
					IsAdmin:  claims.Username == "admin",
					RegisteredClaims: jwt.RegisteredClaims{
						ExpiresAt: &jwt.NumericDate{Time: time.Now().Add(TOKEN_MAX_EXPIRE_HOUR * time.Hour)},
					},
				}
				token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
				tokenString, _ := token.SignedString(jwtKey)
				ctx.Header("new-token", tokenString)
			}
			ctx.Set("claims", claims)
		} else {
			ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": -1, "msg": fmt.Sprintf("Claims parse error: %v", err)})
			return
		}
		ctx.Next()
	}
}

type loginRequest struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

func main() {
	r := gin.Default()
	r.POST("/auth/login", func(ctx *gin.Context) {
		var req loginRequest
		ctx.BindJSON(&req)

		if req.Username != req.Password {
			ctx.JSON(http.StatusOK, gin.H{"code": -1, "msg": "incorrect username or password"})
			return
		}

		log.Printf("login user " + req.Username)

		claims := customClaims{
			Username: req.Username,
			IsAdmin:  req.Username == "admin",
			RegisteredClaims: jwt.RegisteredClaims{
				ExpiresAt: &jwt.NumericDate{Time: time.Now().Add(TOKEN_MAX_EXPIRE_HOUR * time.Hour)},
			},
		}

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

		if tokenString, err := token.SignedString(jwtKey); err != nil {
			ctx.JSON(http.StatusOK, gin.H{"code": -1, "msg": "generate access token failed: " + err.Error()})
		} else {
			ctx.JSON(http.StatusOK, gin.H{"code": 0, "msg": "", "data": tokenString})
		}
	})

	api := r.Group("/api")
	api.Use(AuthRequired())
	api.GET("/test", func(ctx *gin.Context) {
		claims := ctx.MustGet("claims").(*customClaims)
		ctx.JSON(http.StatusOK, gin.H{"code": 0, "data": fmt.Sprintf("current user: %v , is admin: %v", claims.Username, claims.IsAdmin)})
	})

	r.Run(":8080")
}

前端

<template>
  <el-config-provider namespace="ep">
    <el-menu class="el-menu-demo" mode="horizontal">
      <el-menu-item index="1">Vue JWT Demo</el-menu-item>
      <el-menu-item index="2">当前用户: {{ username }}</el-menu-item>
    </el-menu>
    <!-- <img alt="Vue logo" class="element-plus-logo" src="./assets/logo.png" /> -->
    <el-row style="margin-top: 2rem">
      <el-col :span="8"></el-col>
      <el-col :span="8">
        <template v-if="username === ''">
          <el-form :model="form">
            <el-form-item label="用户">
              <el-input v-model="form.username" placeholder="username" />
            </el-form-item>
            <el-form-item label="密码">
              <el-input v-model="form.password" placeholder="password" />
            </el-form-item>
            <el-form-item>
              <el-button type="primary" style="width: 100%" @click="onLogin">获取Token</el-button>
            </el-form-item>
          </el-form>
        </template>
        <template v-else>
          <template v-if="result !== ''">
            <h1>请求成功</h1>
            <h2>{{ result }}</h2>
          </template>
          <el-button type="primary" @click="onAPI">请求API</el-button>
          <el-button @click="onLogout">退出登陆</el-button>
        </template>
      </el-col>
      <el-col :span="8"></el-col>
    </el-row>
    <!-- <HelloWorld msg="Hello Vue 3.0 + Element Plus + Vite" /> -->
  </el-config-provider>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus';

const form = reactive({
  username: '',
  password: '',
})

const username = ref(localStorage.getItem('username') || '')
const result = ref('')

const onLogin = () => {
  axios.post('/auth/login', { username: form.username, password: form.password })
    .then(response => {
      if (response.data.code !== 0) {
        ElMessage.warning(`登陆失败: ${response.data.msg}`)
      } else {
        localStorage.setItem('token', response.data.data);
        localStorage.setItem('username', form.username);
        username.value = form.username;
        ElMessage.info(`${form.username}登陆成功!`)
      }
    })
    .catch(err => {
      console.log(err)
      ElMessage.error(err)
    })
}

const onAPI = () => {
  axios.get('/api/test', { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } })
    .then(response => {
      if (response.headers['new-token']) {
        localStorage.setItem('token', response.headers['new-token']);
      }
      if (response.data.code !== 0) {
        ElMessage.warning(`获取: ${response.data.msg}`)
      } else {
        result.value = response.data.data
        ElMessage.info(`请求成功!`)
      }
    })
    .catch(err => {
      console.log(err)
      ElMessage.error(err)
    })
}

const onLogout = () => {
  localStorage.removeItem('username')
  localStorage.removeItem('token')
  username.value = ''
}
</script>

<style>
#app {
  text-align: center;
  color: var(--ep-text-color-primary);
}

/* 
.element-plus-logo {
  width: 50%;
} */
</style>

三: 其他思路

  • 后端添加一个refresh_token路由,由客户端判断过期时间,请求该路由
	api.GET("/refresh-token", func(ctx *gin.Context) {
		claims := ctx.MustGet("claims").(*customClaims)
		newClaims := customClaims{
			Username: claims.Username,
			IsAdmin:  claims.Username == "admin",
			RegisteredClaims: jwt.RegisteredClaims{
				ExpiresAt: &jwt.NumericDate{Time: time.Now().Add(TOKEN_MAX_EXPIRE_HOUR * time.Hour)},
			},
		}
		token := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
		tokenString, _ := token.SignedString(jwtKey)
		ctx.JSON(http.StatusOK, gin.H{"code": 0, "msg": "", "data": tokenString})
	})
  • 前段存储token时,在localStorage中再添加一个expire时间字段,每次请求API时判断是否快过期,并决定是否更新为新的token和expire时间