JWT 登录后怎么实现“真正退出”?从 ShiyuAdmin 拆解 Go + Gin 的 Token 鉴权与会话撤销

在这里插入图片描述

很多刚接触后台开发的人,在实现登录功能时都会选择 JWT。

流程看起来非常简单:

用户名 + 密码
    ↓
登录成功
    ↓
服务器生成 JWT
    ↓
前端保存 Token
    ↓
每次请求携带 Token

甚至几十行代码就可以实现。

但当你真正开始做后台管理系统,很快就会遇到一个非常经典的问题:

用户点击“退出登录”以后,这个 JWT 为什么还能继续请求接口?

这不是代码写错了。

而是 JWT 本身的设计决定的。

今天我们就拿一个真实 Go 后台项目 ShiyuAdmin 来看看,一个后台管理系统到底应该怎么实现:

登录
↓
JWT 签发
↓
Gin 中间件鉴权
↓
获取当前用户
↓
退出登录
↓
Token 立即失效

项目地址:

https://github.com/Rodert/ShiyuAdmin

后端主要使用 Go + Gin + GORM,并且把认证、权限、中间件、Service、Repository 等模块进行了拆分。


一、先看最简单的 JWT 登录

假设我们有一个登录接口:

POST /api/v1/login

用户提交:

{
  "username": "admin",
  "password": "123456"
}

服务端验证账号密码以后,生成一个 Token:

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "tokenType": "Bearer",
  "expireIn": 7200
}

以后访问用户列表:

GET /api/v1/system/users

请求头带上:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

服务器验证 JWT。

验证通过:

允许访问

验证失败:

401 Unauthorized

这就是最基础的 JWT 鉴权模型。


二、JWT 里面到底存了什么?

很多小白第一次接触 JWT,会把它理解成:

一个随机生成的字符串。

其实不是。

JWT 一般由三部分组成:

Header.Payload.Signature

类似:

xxxxx.yyyyy.zzzzz

其中 Payload 可以保存用户相关的数据。

在 ShiyuAdmin 中,自定义了一个 Claims:

type Claims struct {
    UserCode    string `json:"user_code"`
    Username    string `json:"username"`
    IsSuperAdmin bool  `json:"is_super_admin"`

    jwt.RegisteredClaims
}

这里放了三个非常重要的信息:

UserCode
Username
IsSuperAdmin

也就是说,当服务器解析 JWT 后,就可以知道:

当前是谁?
用户名是什么?
是不是超级管理员?

除此之外,还有 JWT 自带的一些标准字段:

jwt.RegisteredClaims{
    Issuer: issuer,

    ExpiresAt: jwt.NewNumericDate(
        now.Add(time.Duration(expireSeconds) * time.Second),
    ),

    IssuedAt: jwt.NewNumericDate(now),

    NotBefore: jwt.NewNumericDate(now),
}

其中最重要的是:

ExpiresAt

也就是:

Token 什么时候过期。

比如:

登录时间:10:00

过期时间:12:00

那么理论上这个 Token 在 12:00 之前都有效。

问题也恰恰出现在这里。


三、登录成功后怎么生成 JWT?

可以把核心逻辑简化成这样:

func GenerateToken(
    secret string,
    issuer string,
    userCode string,
    username string,
    isSuperAdmin bool,
    expireSeconds int64,
) (string, error) {

    now := time.Now()

    claims := Claims{
        UserCode:     userCode,
        Username:     username,
        IsSuperAdmin: isSuperAdmin,

        RegisteredClaims: jwt.RegisteredClaims{
            Issuer: issuer,

            ExpiresAt: jwt.NewNumericDate(
                now.Add(
                    time.Duration(expireSeconds) *
                        time.Second,
                ),
            ),

            IssuedAt: jwt.NewNumericDate(now),

            NotBefore: jwt.NewNumericDate(now),
        },
    }

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

    return token.SignedString(
        []byte(secret),
    )
}

注意这里:

jwt.SigningMethodHS256

表示使用:

HMAC SHA-256

对 JWT 进行签名。

最终服务器会使用:

secret

生成签名。

这样客户端就不能随便修改:

{
  "is_super_admin": false
}

然后改成:

{
  "is_super_admin": true
}

因为 Payload 一旦变化,签名就对不上了。

服务器验证时就会发现:

Token 被修改过

然后拒绝请求。


四、登录时不能直接比较密码

后台系统里另外一个很容易踩坑的地方就是:

if password == user.Password {
    // 登录成功
}

这种写法意味着数据库里可能保存的是:

123456

这样的明文密码。

这是非常危险的。

ShiyuAdmin 登录时使用的是 bcrypt:

bcrypt.CompareHashAndPassword(
    []byte(user.Password),
    []byte(req.Password),
)

这里:

user.Password

是数据库里保存的密码 Hash。

而:

req.Password

是用户登录时输入的密码。

数据库保存的应该类似:

$2a$10$xxxxxxxxxxxxxxxxxxxxxxxx

而不是:

123456

登录流程就变成:

用户输入密码

       ↓

bcrypt.CompareHashAndPassword()

       ↓

比较 Hash

       ↓

成功

这样即使数据库泄露,攻击者也不会直接拿到用户密码明文。


五、完整登录流程应该长这样

在 ShiyuAdmin 这类分层项目中,登录逻辑通常放在 Service。

可以简化成:

func (s *Service) Login(
    ctx context.Context,
    req *LoginRequest,
) (*TokenVO, error) {

    // 1. 查询用户
    user, err :=
        s.repo.FindUserByUsername(
            ctx,
            req.Username,
        )

    if err != nil {
        return nil, err
    }

    // 2. 判断用户是否存在
    if user == nil {
        return nil,
            errors.New("用户名或密码错误")
    }

    // 3. 校验密码
    if err :=
        bcrypt.CompareHashAndPassword(
            []byte(user.Password),
            []byte(req.Password),
        ); err != nil {

        return nil,
            errors.New("用户名或密码错误")
    }

    // 4. 判断账号状态
    if user.Status != 1 {
        return nil,
            errors.New("账号已停用")
    }

    // 5. 生成 JWT
    token, err :=
        jwtutil.GenerateToken(
            s.jwtSecret,
            s.jwtIssuer,
            user.UserCode,
            user.Username,
            user.IsSuperAdmin,
            s.jwtExpireIn,
        )

    if err != nil {
        return nil, err
    }

    // 6. 返回 Token
    return &TokenVO{
        Token: token,

        TokenType: "Bearer",

        ExpireIn:
            s.jwtExpireIn,
    }, nil
}

这样看就非常清楚了:

查用户
  ↓
校验密码
  ↓
检查账号状态
  ↓
生成 JWT
  ↓
返回 Token

这里有一个细节值得注意。

用户名不存在和密码错误,都返回:

用户名或密码错误

而不是:

用户不存在

或者:

密码错误

为什么?

因为如果分别返回:

用户不存在

攻击者就可以不断测试:

admin
root
test
zhangsan
lisi

从而判断哪些账号真实存在。

统一返回:

用户名或密码错误

可以减少用户枚举风险。


六、JWT 生成以后,Gin 怎么保护接口?

有 Token 以后,还必须写一个:

认证中间件

否则你的接口根本不知道:

这个请求有没有登录。

Gin 中可以这样写:

func Auth(secret string) gin.HandlerFunc {

    return func(c *gin.Context) {

        authHeader :=
            c.GetHeader(
                "Authorization",
            )

        if authHeader == "" {

            c.JSON(
                401,
                gin.H{
                    "message": "未授权",
                },
            )

            c.Abort()

            return
        }

        c.Next()
    }
}

然后给需要登录的接口挂上:

authorized :=
    router.Group("/api/v1")

authorized.Use(
    Auth(jwtSecret),
)

以后访问:

GET /api/v1/users

都会先进入:

Auth Middleware

验证通过以后,才真正执行:

UserHandler

整个请求链路其实是:

浏览器
   ↓
Gin Router
   ↓
Auth Middleware
   ↓
Handler
   ↓
Service
   ↓
Repository
   ↓
Database

七、为什么 Authorization 要写成 Bearer Token?

我们经常看到:

Authorization: Bearer xxxxx

为什么不是:

Authorization: xxxxx

因为标准 Authorization Header 一般由两部分组成:

认证类型 + 凭证

比如:

Bearer Token

所以代码通常先拆:

parts :=
    strings.Fields(
        authHeader,
    )

然后验证:

if len(parts) != 2 ||
   !strings.EqualFold(
       parts[0],
       "Bearer",
   ) {

    // 无效认证头
}

正确格式:

Bearer abcdefg

错误格式:

abcdefg

错误:

Token abcdefg

错误:

Bearer

正确:

Authorization:
Bearer eyJhbGciOiJIUzI1NiIs...

八、接下来才是真正解析 JWT

拿到:

parts[1]

也就是 Token 后,就可以解析。

核心逻辑类似:

func ParseToken(
    secret string,
    tokenString string,
) (*Claims, error) {

    token, err :=
        jwt.ParseWithClaims(
            tokenString,
            &Claims{},

            func(
                token *jwt.Token,
            ) (interface{}, error) {

                return []byte(secret), nil
            },
        )

    if err != nil {
        return nil, err
    }

    claims, ok :=
        token.Claims.(*Claims)

    if !ok || !token.Valid {
        return nil,
            errors.New(
                "invalid token",
            )
    }

    return claims, nil
}

验证通过以后,就得到了:

claims.UserCode

claims.Username

claims.IsSuperAdmin

于是服务器就知道:

现在到底是谁在访问接口。


九、解析完 JWT,为什么还要放进 Gin Context?

这一步很重要。

很多新手会在每一个 Handler 里面重新解析 Token:

func GetUsers(c *gin.Context) {

    token :=
        c.GetHeader(
            "Authorization",
        )

    // 再解析 JWT
}

另一个接口:

func DeleteUser(c *gin.Context) {

    token :=
        c.GetHeader(
            "Authorization",
        )

    // 又解析一次 JWT
}

这样重复代码会越来越多。

更好的方式是在 Middleware 里只解析一次。

例如:

c.Set(
    "currentUser",
    claims,
)

然后:

c.Next()

后面的 Handler 直接:

value, exists :=
    c.Get("currentUser")

即可。

整个结构就变成:

JWT
 ↓
Auth Middleware
 ↓
解析 Claims
 ↓
写入 Gin Context
 ↓
Handler
 ↓
获取 CurrentUser

这是 Gin 项目中非常常见的一种设计。


十、重点来了:JWT 最大的问题是什么?

假设现在:

Token 有效期 = 2 小时

用户:

10:00 登录

那么 Token:

12:00 过期

现在用户在:

10:10 点击退出登录

前端把 Token 删除。

看起来已经退出了。

但是有一个问题。

如果有人提前复制了这个 Token:

eyJhbGciOiJIUzI1NiIs...

继续发送:

Authorization:
Bearer eyJhbGciOiJIUzI1NiIs...

服务器会发现:

签名正确

没有过期

Claims 正常

于是:

鉴权成功

也就是说:

JWT 默认情况下,退出登录并不能让已经签发的 Token 立即失效。

这是理解 JWT 最关键的地方之一。


十一、为什么会这样?

因为标准 JWT 最大的特点之一就是:

无状态

服务器签发 Token 后:

服务器不一定需要保存 Token

请求过来以后,只需要:

验证签名
+
检查过期时间

就行。

例如:

客户端 Token
      ↓
服务器
      ↓
验证 secret
      ↓
验证 exp
      ↓
通过

服务器甚至不需要查询数据库。

这就是 JWT 的优势。

但同时也带来了问题。

服务器既然:

没有保存 Token 状态

那么用户退出以后:

服务器怎么知道这个 Token 已经退出了?

不知道。

所以如果需要:

立即踢人
强制下线
退出立即失效
封禁账户立即失效
修改密码立即下线

仅仅依靠 JWT 就不够了。


十二、ShiyuAdmin 的思路:JWT + Session

这里就出现了一个非常实用的设计:

JWT
+
Session 状态

请求过来以后不仅:

验证 JWT

还会:

检查 Session 是否已经撤销

变成:

客户端
   ↓
JWT
   ↓
验证签名
   ↓
检查过期时间
   ↓
计算 Session ID
   ↓
查询 Session 状态
   ↓
是否已撤销?
  ↙      ↘
是        否
↓          ↓
401       放行

这样就解决了 JWT 无法主动失效的问题。


十三、怎么给 JWT 生成一个 Session ID?

一个办法是直接保存整个 Token。

例如:

session:
eyJhbGciOiJIUzI1NiIs...

但 JWT 往往很长。

直接拿完整 JWT 当 Redis Key 并不漂亮。

ShiyuAdmin 使用了另一种思路:

Token
 ↓
SHA-256
 ↓
截取一部分
 ↓
Session ID

可以简化成:

func SessionIDFromToken(
    token string,
) string {

    sum :=
        sha256.Sum256(
            []byte(token),
        )

    value :=
        hex.EncodeToString(
            sum[:],
        )

    return strings.ToUpper(
        value[:16],
    )
}

比如 Token:

eyJhbGciOiJIUzI1NiIsInR5...

经过 SHA-256:

9e107d9d372bb6826bd81d3542a419d6...

最后得到:

9E107D9D372BB682

这个字符串就可以作为:

Session ID

十四、为什么不直接把 JWT 存 Redis?

当然可以。

比如:

KEY

blacklist:
eyJhbGciOiJIUzI1NiIs...

但完整 JWT 可能比较长。

如果系统存在:

几万
几十万
上百万

个 Session,就没有必要把完整 Token 全部作为 Key 保存。

Hash 后:

Token

↓

固定长度 SessionID

更方便管理。


十五、鉴权中间件就发生变化了

普通 JWT 中间件:

读取 Token
↓
解析 Token
↓
通过

ShiyuAdmin 这种设计:

读取 Token
↓
解析 Token
↓
生成 SessionID
↓
检查 Session 是否被撤销
↓
通过

核心逻辑可以理解成:

claims, err :=
    jwtutil.ParseToken(
        secret,
        parts[1],
    )

if err != nil {

    response.Error(
        c,
        http.StatusUnauthorized,
        "令牌无效或已过期",
    )

    c.Abort()

    return
}

JWT 没问题以后:

sessionID :=
    SessionIDFromToken(
        parts[1],
    )

然后再:

revoked, err :=
    validator.IsSessionRevoked(
        c.Request.Context(),
        sessionID,
    )

如果已经撤销:

if revoked {

    response.Error(
        c,
        http.StatusUnauthorized,
        "会话已登出",
    )

    c.Abort()

    return
}

最终才:

c.Set(
    "currentUser",
    claims,
)

c.Set(
    "sessionID",
    sessionID,
)

c.Next()

这时候整个鉴权才真正结束。


十六、这样“退出登录”就很好实现了

用户退出时:

POST /api/v1/logout

服务器拿到:

SessionID

然后标记:

revoked

例如使用 Redis:

func RevokeSession(
    ctx context.Context,
    sessionID string,
    ttl time.Duration,
) error {

    key :=
        "revoked_session:" +
            sessionID

    return redisClient.Set(
        ctx,
        key,
        "1",
        ttl,
    ).Err()
}

于是 Redis 可能出现:

revoked_session:
9E107D9D372BB682

值:

1

TTL:

1h42m

为什么还需要 TTL?

因为 JWT 自己也有过期时间。

假设 Token:

12:00 自动过期

现在:

10:18 退出

只需要把撤销记录保存:

1 小时 42 分钟

即可。

12:00 以后:

JWT 自己已经失效

Redis 就没必要继续保存黑名单。

这也是非常常见的一种优化。


十七、检查 Session 是否撤销

查询也非常简单:

func IsSessionRevoked(
    ctx context.Context,
    sessionID string,
) (bool, error) {

    key :=
        "revoked_session:" +
            sessionID

    count, err :=
        redisClient.Exists(
            ctx,
            key,
        ).Result()

    if err != nil {
        return false, err
    }

    return count > 0, nil
}

于是每次请求:

JWT 验证成功

↓

Redis Exists

↓

不存在

↓

继续访问

如果存在:

JWT 验证成功

↓

Redis Exists

↓

存在

↓

401 会话已登出

这就是所谓:

JWT + 服务端会话状态

它其实是:

无状态认证
+
少量状态控制

的折中方案。


十八、为什么后台管理系统很适合这样做?

如果只是一个:

普通内容网站

可能纯 JWT 已经够用了。

但后台管理系统通常存在这些场景:

管理员强制用户下线

账号被禁用

员工离职

用户修改密码

发现账号被盗

删除管理员

权限发生重大变化

假设一个员工离职。

管理员:

11:00 禁用账号

但他的 JWT:

18:00 才过期

如果只使用普通 JWT:

这个 Token 理论上还能使用 7 个小时。

显然不合理。

后台系统更加希望:

点击禁用

↓

立即失效

这时候:

Session Revoke

就非常重要了。


十九、JWT 和 Redis Session 到底怎么选?

很多小白容易陷入:

JWT 和 Session 哪个好?

其实并不是非黑即白。

纯 Session

流程:

Cookie
 ↓
SessionID
 ↓
Redis
 ↓
User

优点:

容易主动失效
容易踢人
状态好控制

缺点:

每次请求都依赖 Session Store

纯 JWT

流程:

JWT
 ↓
验证签名
 ↓
Claims

优点:

简单
无状态
扩展方便

缺点:

已经签发的 Token
很难主动失效

JWT + Session 状态

流程:

JWT
 ↓
验证身份
 ↓
SessionID
 ↓
检查是否撤销

优点:

保留 JWT 的便利

同时拥有强制下线能力

所以很多真实后台系统并不会教条地说:

必须完全无状态

而是:

根据业务需求,在 JWT 上补一层服务端状态控制。


二十、完整认证链路梳理

把 ShiyuAdmin 这一套思路串起来,就会非常清楚。

登录:

POST /login

↓

查询用户

↓

bcrypt 校验密码

↓

检查账号状态

↓

生成 JWT

↓

返回 Bearer Token

访问接口:

GET /users

↓

Authorization:
Bearer xxx

↓

Auth Middleware

↓

解析 JWT

↓

验证签名

↓

验证过期时间

↓

获取 Claims

↓

生成 SessionID

↓

检查 Session 是否撤销

↓

写入 Gin Context

↓

继续执行接口

退出登录:

POST /logout

↓

获取 SessionID

↓

记录 Session 已撤销

↓

再次请求接口

↓

JWT 本身依然有效

↓

但是 Session 已撤销

↓

401 Unauthorized

这时候:

退出登录

才算真正完成。


二十一、为什么 Middleware 特别适合做鉴权?

还有一个架构上的问题:

为什么不直接在 Handler 里判断登录?

比如:

func DeleteUser(c *gin.Context) {

    if !checkToken(c) {
        return
    }

    // 删除用户
}

然后:

func CreateUser(c *gin.Context) {

    if !checkToken(c) {
        return
    }

    // 创建用户
}

再:

func UpdateRole(c *gin.Context) {

    if !checkToken(c) {
        return
    }

    // 修改角色
}

你会发现:

每个接口都在重复鉴权。

而 Middleware 可以统一处理:

api.Use(
    middleware.Auth(
        jwtSecret,
    ),
)

于是:

所有受保护接口

↓

自动鉴权

Handler 只需要负责自己的业务。

这就是中间件真正的价值:

把与具体业务无关、但每次请求都需要执行的逻辑抽出来。

例如:

JWT 鉴权
日志
Trace ID
CORS
限流
权限判断
操作审计

都非常适合 Middleware。


二十二、JWT 鉴权和权限校验不是一回事

这里也是新手最容易混淆的地方。

JWT Authentication 解决的是:

你是谁?

例如:

userCode = U001

username = admin

但它并没有回答:

你能不能删除用户?

后者属于:

Authorization

也就是权限控制。

所以完整后台应该是:

请求

↓

JWT Authentication

↓

你是谁?

↓

Permission Authorization

↓

你有没有权限?

↓

Handler

例如:

admin

已经登录。

但如果没有:

system:user:delete

权限。

依然应该返回:

403 Forbidden

所以:

401

和:

403

也是有区别的。

401:

我不知道你是谁。

403:

我知道你是谁,
但是你没权限。

这是做后台系统时必须理解的概念。


二十三、如果自己写项目,我建议这样拆目录

类似 ShiyuAdmin,可以把认证相关代码拆成:

internal/

├── middleware/
│   ├── auth.go
│   └── permission.go
│
├── service/
│   └── auth/
│       └── service.go
│
├── repository/
│   ├── interfaces/
│   │   └── auth.go
│   │
│   └── db/
│       └── auth_repo.go
│
└── model/
    ├── dto/
    │   └── auth.go
    │
    └── vo/
        └── auth.go

pkg/

└── jwtutil/
    └── token.go

职责分别是:

auth.go middleware

负责:
请求鉴权
auth/service.go

负责:
登录业务
auth_repo.go

负责:
查询用户
jwtutil/token.go

负责:
JWT 生成和解析

这样以后换认证方案时,就不会所有代码全部堆在:

login.go

里面。


二十四、一个完整项目里,JWT Secret 千万不要写死

新手经常这么写:

var jwtSecret =
    "123456"

更危险的是:

const jwtSecret =
    "admin"

正确方式应该通过配置读取:

jwt:
  secret: ${JWT_SECRET}
  issuer: shiyu-admin
  expire-time: 7200

生产环境:

export JWT_SECRET="一段足够长的随机字符串"

程序读取:

authSvc :=
    auth.New(
        authRepo,
        cfg.JWT.Secret,
        cfg.JWT.Issuer,
        cfg.JWT.ExpireTime,
    )

这样 Secret:

开发环境
测试环境
生产环境

都可以独立管理。

更重要的是:

不要把生产环境真正使用的 JWT Secret 提交到 GitHub。


二十五、最后总结

如果只是学习 JWT,代码其实并不复杂:

登录
↓
生成 Token
↓
请求携带 Token
↓
解析 Token

但真正进入项目以后,你很快就会发现:

登录只是认证系统最简单的一部分。

真正需要解决的是:

Token 怎么过期?

怎么退出?

怎么强制下线?

账号禁用后怎么办?

怎么知道当前用户?

怎么区分登录和权限?

怎么避免每个接口重复鉴权?

ShiyuAdmin 这里比较值得学习的一点,就是没有把 JWT 简单理解成:

生成一个 Token 就结束了

而是把整个链路拆成:

bcrypt 密码校验
        ↓
JWT 签发
        ↓
Gin Auth Middleware
        ↓
Claims
        ↓
Session ID
        ↓
Session Revocation
        ↓
Gin Context
        ↓
Permission
        ↓
业务接口

如果你正在用 Go + Gin 写后台管理系统,我认为至少应该理解这套流程。

特别是:

JWT 本身并不等于完整的登录系统。

生成 Token 很简单。

真正难的是:

Token 签发以后,怎么安全地管理它的整个生命周期。

posted @ 2026-09-03 10:19  JavaPub  阅读(9)  评论(0)    收藏  举报