一、背景
痛点:
- 用单一的 token 来实现频繁的请求,泄露后较为危险。需要有一个长 token 。这个 token 只在登录和调用
/refresh_token
时使用,所以不容易泄露。 - 受到微信登录开发指南的启发,将目前用于实现登录的单个 JWT 改造成 长短 token,并将所有关于 JWT 的逻辑整合到
JwtHandler
中。
介绍:
- 短 token :用于访问资源,一般也叫做
access_token
- 长 token :在短 token 过期之后,用于生成一个新的短 token 。一般也叫做
refresh_token
约定:
- 登录成功后,后端分别将长短 token 添加到 响应头
x-refresh-token
和x-jwt-token
中。假定长 token 的过期时间为 7天 ,短 token 为 1min。若短 token 过期,则前端会收到 401 的请求后,需要调用相应/refresh_token
接口拿新的短 token;若长 token 过期,则退出登录。 - 在前端请求
/refresh_token
接口来刷新短 token 时,需要将refresh_token
放入请求头的Authorization
中。
二、实现
(1)关于 JWT 的逻辑整合到一起,形成 JwtHandler
package jwt
import "github.com/gin-gonic/gin"
type Handler interface {
// 从前端请求中,提取tokenStr
ExtractToken(ctx *gin.Context) string
// 检查是否退出登录
CheckSsid(ctx *gin.Context, ssid string) error
// 设置短token
SetJWTToken(ctx *gin.Context, uid int64, ssid string) error
// 设置长短token
SetLoginToken(ctx *gin.Context, uid int64) error
// 删除长短token
ClearToken(ctx *gin.Context) error
}
package jwt
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"strings"
"time"
)
type RedisJWTHandler struct {
client redis.Cmdable
// 长token和ssid的过期时间
rcExpiration time.Duration
}
func NewRedisJWTHandler(client redis.Cmdable) Handler {
return &RedisJWTHandler{
client: client,
rcExpiration: 7 * 24 * time.Hour,
}
}
// ExtractToken note 从 header 中的 Authorization 中 提取形如 “Bear **”的 token
func (h *RedisJWTHandler) ExtractToken(ctx *gin.Context) string {
header := ctx.GetHeader("Authorization")
if header == "" {
// note 此处不需要处理,因为在后续解析 token 时会报错的
return ""
}
if len(strings.Split(header, " ")) != 2 {
// header不是Bear ** 形式
return ""
}
tokenStr := strings.Split(header, " ")[1]
return tokenStr
}
// 检查是否退出登录
func (h *RedisJWTHandler) CheckSsid(ctx *gin.Context, ssid string) error {
cnt, err := h.client.Exists(ctx, fmt.Sprintf("user:ssid:%s", ssid)).Result()
// note 这种写法过于生硬,因为若redis崩了,正常登录着的用户也会无法通过这个判断导致返回401【可不处理err来降级处理,以兼容redis异常的情况】
// note 要保证尽量提供服务,即使是有损的服务也比没服务好
if err != nil {
return err
}
if cnt > 0 {
return errors.New("该用户已退出")
}
return nil
}
func (h *RedisJWTHandler) SetJWTToken(ctx *gin.Context, uid int64, ssid string) error {
uc := UserClaims{
Uid: uid,
Ssid: ssid,
UserAgent: ctx.GetHeader("user-agent"),
// 定义JWT过期时间 —— 1min
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
},
}
// 此token只是jwt的一个token结构体
token := jwt.NewWithClaims(jwt.SigningMethodHS512, uc)
// 此tokenStr才是传输的token
tokenStr, err := token.SignedString(JWTKey)
if err != nil {
return err
}
// 添加进响应的header中
ctx.Header("x-jwt-token", tokenStr)
return nil
}
// SetLoginToken note 在登录成功后,设置长短token和用于退出登录的ssid
func (h *RedisJWTHandler) SetLoginToken(ctx *gin.Context, uid int64) error {
ssid := uuid.New().String()
err := h.setRefreshToken(ctx, uid, ssid)
if err != nil {
return err
}
return h.SetJWTToken(ctx, uid, ssid)
}
func (h *RedisJWTHandler) ClearToken(ctx *gin.Context) error {
// 1. 给前端非法的token
ctx.Header("x-jwt-token", "")
ctx.Header("x-refresh-token", "")
// 2. 在redis写入ssid
uc := ctx.MustGet("user").(UserClaims)
// note 将ssid的过期时间设置为长token的过期时间
return h.client.Set(ctx, fmt.Sprintf("user:ssid:%s", uc.Ssid), "", h.rcExpiration).Err()
}
func (h *RedisJWTHandler) setRefreshToken(ctx *gin.Context, uid int64, ssid string) error {
rc := RefreshClaims{
Uid: uid,
Ssid: ssid,
RegisteredClaims: jwt.RegisteredClaims{
// 长token过期时间为7天
ExpiresAt: jwt.NewNumericDate(time.Now().Add(h.rcExpiration)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, rc)
tokenStr, err := token.SignedString(RefreshKey)
if err != nil {
return err
}
ctx.Header("x-refresh-token", tokenStr)
return nil
}
var JWTKey = []byte("oIft1b5qZjyLcc0zZo2UrUx5rk3KE0LvZKv73fw502oXd6vfYu1OAQvbSel8whvm")
var RefreshKey = []byte("oIft1b5qZjyLcc0zZo2UrUx5r80iz0LvZKv73fw502oXd6vfYu1OAQvbSel8whvm")
type RefreshClaims struct {
jwt.RegisteredClaims
Uid int64
Ssid string
}
type UserClaims struct {
jwt.RegisteredClaims
Uid int64
// note 利用请求头的User-Agent来增强安全性(防止jwt被攻击者获取) User-Agent含有浏览器的信息
UserAgent string
Ssid string
}
(2)Handler层的代码:
func (h *UserHandler) RefreshToken(ctx *gin.Context) {
// note 约定:前端在请求刷新短token时,会将refresh_token放在Authorization中
refreshTokenStr := h.ExtractToken(ctx)
var rc ijwt.RefreshClaims
token, err := jwt.ParseWithClaims(refreshTokenStr, &rc, func(token *jwt.Token) (interface{}, error) {
return ijwt.RefreshKey, nil
})
if err != nil {
ctx.JSON(http.StatusUnauthorized, Result{
Code: 4,
Msg: "refresh_token无效",
})
return
}
if token == nil || !token.Valid {
ctx.JSON(http.StatusUnauthorized, Result{
Code: 4,
Msg: "refresh_token无效",
})
return
}
err = h.CheckSsid(ctx, rc.Ssid)
if err != nil {
// redis有问题 或 已退出登录
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
err = h.SetJWTToken(ctx, rc.Uid, rc.Ssid)
if err != nil {
ctx.JSON(http.StatusOK, Result{
Code: 5,
Msg: "系统错误",
})
return
}
ctx.JSON(http.StatusOK, Result{
Msg: "刷新成功",
})
}
func (h *UserHandler) LogoutJWT(ctx *gin.Context) {
err := h.ClearToken(ctx)
if err != nil {
ctx.JSON(http.StatusOK, Result{
Code: 5,
Msg: "系统错误",
})
return
}
ctx.JSON(http.StatusOK, Result{
Msg: "退出登录成功",
})
}
三、深入讨论
-
问:如果长 token 也泄露了怎么办?
答:但凡你的长 token 泄露了,你就可以认为你能做的事情已经不多了。缓解的方式可以是:长 token 里面编码必要的登录时的信息,比如说
User-Agent
这种,或者麻烦前端的同事加入浏览器指纹
的校验