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 签发以后,怎么安全地管理它的整个生命周期。

浙公网安备 33010602011771号