掘金 后端 ( ) • 2024-06-30 14:22

本文关键词:JWT、Redis实现退出登录

一、登录部分

痛点:虽然有人为 Gin 提供了 JWT 插件,但是插件过于复杂并且不好用。而且它屏蔽了和 JWT 有关的操作,不易于理解和学习,所以我们直接使用 JWT 原始 API 来设计一个登录和退出校验

jwt 原始 APIgo install github.com/golang-jwt/jwt/v5

前后端约定

  1. 后端给前端的 JWT 放在了 Header 中,字段名为 x-jwt-token
  2. 希望前端在请求的 Authorization 字段带上形如 Bear ${token}的 JWT

image.png

JWT 的基本使用

(1)JWT 加密 (常用于后端生成 JWT 再传给前端)

var JWTKey = []byte("oIft1b5qZjyLcc0zZo2UrUx5rk3KE0LvZKv73fw502oXd6vfYu1OAQvbSel8whvm")

type UserClaims struct {
    jwt.RegisteredClaims
    Uid int64
    Ssid string // 用于记录是否退出登录
}

func (h *RedisJWTHandler) SetJWTToken(ctx *gin.Context, uid, ssid int64) error {
    uc := UserClaims{
       Uid:       uid,
       SSid :     ssid,
       // 定义JWT过期时间 —— 1min
       RegisteredClaims: jwt.RegisteredClaims{
          ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
       },
    }
    // 创建一个 JWT claim声明(此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
}

(2)JWT 解密(常用于后端接收前端的 JWT 再解密,进而实现校验)

func GetJWTToken(ctx *gin.Context) error {
    header := ctx.GetHeader("Authorization")
    if header == "" {
        // note 此处不需要处理,因为在后续解析 token 时会报错的
        return ""
    }
    if len(strings.Split(header, " ")) != 2 {
        // header不是Bear ** 形式
        return ""
    }
    tokenStr := strings.Split(header, " ")[1]
    
    uc := ijwt.UserClaims{}
    // note 1. keyfunc的作用是生成更高级的JWTKey,但我们不需要对key设计func,用固定的即可。 2. &uc不是uc
    token, err := jwt.ParseWithClaims(tokenStr, &uc, func(token *jwt.Token) (interface{}, error) {
        return ijwt.JWTKey, nil
    })
    if err != nil {
        // token解析不出来(可能是伪造的)
        ctx.AbortWithStatus(http.StatusUnauthorized)
        return
    }
    if !token.Valid {
        // token解析出来了,但过期了( uc.ExpireAt.Before(time.Now()) == True)
        ctx.AbortWithStatus(http.StatusUnauthorized)
        return
    }
    if uc.UserAgent != ctx.GetHeader("user-agent") {
        // todo 埋点,正常用户不会进入该分支
        ctx.AbortWithStatus(http.StatusUnauthorized)
        return
    }
}

二、退出部分

痛点与解决方案

  1. 如果是用 Session 实现的登录,那么在实现退出时,只需要删除 Session (Cookie、Redis等),但 JWT 是无状态的,无法直接删除。JWT 要退出登录,就只能考虑用一个额外的东西来记录这个 JWT 已经不可用了。而且考虑到程序可能部署在了多个实例上,如下图,退出登录的请求落到了实例 0 上,但是后续的请求落到了实例 1 上。 所以必须要让实例 1 能够感知到,用户已经在实例 0 上退出登录了。所以用 Redis 记录 token 是否可用
图片说明
  1. 如果记录的是可用的 token, 数据量会很大。考虑到退出登录是一个低频的动作,所以记录不可用的 token
  2. 不方便直接将长 token 存入 redis ,所以用另一个字段 ssid 来标识是否退出

image.png

实现

type RedisJWTHandler struct {
    client redis.Cmdable
    // 长token和ssid的过期时间
    rcExpiration time.Duration
}

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) 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()
}

middleware 中的调用:

// 退出登录的校验
err = b.CheckSsid(ctx, uc.Ssid)
if err != nil {
    // redis有问题 或 已退出登录
    ctx.AbortWithStatus(http.StatusUnauthorized)
    return
}