本文关键词:JWT、Redis实现退出登录
一、登录部分
痛点:虽然有人为 Gin 提供了 JWT 插件,但是插件过于复杂并且不好用。而且它屏蔽了和 JWT 有关的操作,不易于理解和学习,所以我们直接使用 JWT 原始 API 来设计一个登录和退出校验
jwt 原始 API:go install github.com/golang-jwt/jwt/v5
前后端约定:
- 后端给前端的 JWT 放在了 Header 中,字段名为
x-jwt-token
- 希望前端在请求的 Authorization 字段带上形如
Bear ${token}
的 JWT
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
}
}
二、退出部分
痛点与解决方案:
- 如果是用 Session 实现的登录,那么在实现退出时,只需要删除 Session (Cookie、Redis等),但 JWT 是无状态的,无法直接删除。JWT 要退出登录,就只能考虑用一个额外的东西来记录这个 JWT 已经不可用了。而且考虑到程序可能部署在了多个实例上,如下图,退出登录的请求落到了实例 0 上,但是后续的请求落到了实例 1 上。 所以必须要让实例 1 能够感知到,用户已经在实例 0 上退出登录了。所以用 Redis 记录 token 是否可用。
- 如果记录的是可用的 token, 数据量会很大。考虑到退出登录是一个低频的动作,所以记录不可用的 token。
- 不方便直接将长 token 存入 redis ,所以用另一个字段 ssid 来标识是否退出。
实现:
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
}