掘金 后端 ( ) • 2024-06-30 17:58

一、背景

痛点

  1. 用单一的 token 来实现频繁的请求,泄露后较为危险。需要有一个长 token 。这个 token 只在登录和调用 /refresh_token 时使用,所以不容易泄露。
  2. 受到微信登录开发指南的启发,将目前用于实现登录的单个 JWT 改造成 长短 token,并将所有关于 JWT 的逻辑整合到 JwtHandler 中。

介绍

  • 短 token :用于访问资源,一般也叫做 access_token
  • 长 token :在短 token 过期之后,用于生成一个新的短 token 。一般也叫做 refresh_token

image.png

约定

  1. 登录成功后,后端分别将长短 token 添加到 响应头 x-refresh-tokenx-jwt-token中。假定长 token 的过期时间为 7天 ,短 token 为 1min。若短 token 过期,则前端会收到 401 的请求后,需要调用相应 /refresh_token 接口拿新的短 token;若长 token 过期,则退出登录。
  2. 在前端请求/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: "退出登录成功",
    })
}

三、深入讨论

  1. 问:如果长 token 也泄露了怎么办?

    答:但凡你的长 token 泄露了,你就可以认为你能做的事情已经不多了。缓解的方式可以是:长 token 里面编码必要的登录时的信息,比如说 User-Agent 这种,或者麻烦前端的同事加入 浏览器指纹 的校验