topfans/docs/superpowers/specs/2026-05-14-redis-token-blacklist-design.md
zerosaturation c38ad5a654 docs: 优化 Redis Token 黑名单设计
- 使用 SHA256 哈希代替原始 token 作为 Key
- 使用 JSON 格式存储 value,避免解析问题
- 添加输入验证

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 16:21:42 +08:00

256 lines
6.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# JWT Token 黑名单设计文档
> **创建日期:** 2026-05-14
> **更新日期:** 2026-05-14
> **项目:** TopFans Redis Token 黑名单
> **服务:** Gateway / 通用
> **状态:** 设计中
---
## 一、设计目标
实现 JWT Token 黑名单功能,用于:
1. **账号封禁时** - 管理员封禁用户Token 立即加入黑名单
2. **强制下线时** - 用户在多个设备登录,需要让某个 token 失效
---
## 二、技术方案
### 2.1 技术选型
- **客户端**: `github.com/redis/go-redis/v9`
- **连接信息**: 通过环境变量配置
### 2.2 Redis Key 设计
| Key 格式 | Value | TTL |
|---------|-------|-----|
| `blacklist:token:{tokenHash}` | `{"user_id":123,"reason":"封禁原因"}` | 与 Token 剩余有效期一致 |
**Key 结构说明:**
- `tokenHash`: 使用 SHA256 对 Token 进行哈希,避免使用长字符串作为 Key
- 这样做既保证了唯一性,又节省 Redis 内存
**Value 结构说明:**
- JSON 格式存储,包含 `user_id``reason` 字段
- 使用 JSON 格式避免 value 中包含特殊字符导致的解析问题
**TTL 说明:**
- TTL 与 Token 剩余有效期一致Token 过期后自动清理黑名单记录
### 2.3 核心接口
```go
package database
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// RedisClient Redis 客户端
var RedisClient *redis.Client
// Config Redis 配置
type RedisConfig struct {
Host string
Port int
Password string
DB int
}
// Init 初始化 Redis 连接
func InitRedis(cfg RedisConfig) error {
RedisClient = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: cfg.DB,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := RedisClient.Ping(ctx).Err(); err != nil {
return fmt.Errorf("failed to connect to redis: %w", err)
}
return nil
}
// Close 关闭 Redis 连接
func CloseRedis() error {
if RedisClient != nil {
return RedisClient.Close()
}
return nil
}
// GetRedis 获取 Redis 客户端实例
func GetRedis() *redis.Client {
return RedisClient
}
// HealthCheck 健康检查
func RedisHealthCheck() error {
if RedisClient == nil {
return fmt.Errorf("redis client is not initialized")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return RedisClient.Ping(ctx).Err()
}
```
### 2.4 Token 黑名单操作
```go
package database
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
const (
BlacklistKeyPrefix = "blacklist:token:"
)
// BlacklistEntry 黑名单条目
type BlacklistEntry struct {
UserID int64 `json:"user_id"`
Reason string `json:"reason"`
}
// tokenToHash 将 Token 转换为哈希作为 Key
func tokenToHash(token string) string {
hash := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", hash)
}
// AddToBlacklist 添加 Token 到黑名单
// token: JWT Token 字符串
// userID: 用户 ID
// banReason: 封禁原因
// ttl: Token 剩余有效期
func AddToBlacklist(ctx context.Context, token string, userID int64, banReason string, ttl time.Duration) error {
if token == "" {
return fmt.Errorf("token is empty")
}
if RedisClient == nil {
return fmt.Errorf("redis client is not initialized")
}
key := BlacklistKeyPrefix + tokenToHash(token)
entry := BlacklistEntry{UserID: userID, Reason: banReason}
value, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("failed to marshal blacklist entry: %w", err)
}
return RedisClient.Set(ctx, key, value, ttl).Err()
}
// IsBlacklisted 检查 Token 是否在黑名单
// 返回: (是否在黑名单, 用户ID, 封禁原因, error)
func IsBlacklisted(ctx context.Context, token string) (bool, int64, string, error) {
if token == "" {
return false, 0, "", nil
}
if RedisClient == nil {
return false, 0, "", fmt.Errorf("redis client is not initialized")
}
key := BlacklistKeyPrefix + tokenToHash(token)
value, err := RedisClient.Get(ctx, key).Result()
if err == redis.Nil {
return false, 0, "", nil
}
if err != nil {
return false, 0, "", err
}
var entry BlacklistEntry
if err := json.Unmarshal([]byte(value), &entry); err != nil {
return false, 0, "", fmt.Errorf("failed to unmarshal blacklist entry: %w", err)
}
return true, entry.UserID, entry.Reason, nil
}
// RemoveFromBlacklist 从黑名单移除 Token用于解封
func RemoveFromBlacklist(ctx context.Context, token string) error {
if token == "" {
return nil
}
if RedisClient == nil {
return fmt.Errorf("redis client is not initialized")
}
key := BlacklistKeyPrefix + tokenToHash(token)
return RedisClient.Del(ctx, key).Err()
}
```
---
## 三、配置项
### 3.1 环境变量
| 变量名 | 说明 | 默认值 |
|--------|------|-------|
| `REDIS_HOST` | Redis 主机地址 | 127.0.0.1 |
| `REDIS_PORT` | Redis 端口 | 6379 |
| `REDIS_PASSWORD` | Redis 密码 | (空) |
| `REDIS_DB` | Redis 数据库编号 | 0 |
---
## 四、调用位置
| 场景 | 位置 | 触发时机 |
|------|------|---------|
| **账号封禁** | 管理员操作 → 更新账号状态 → **调用 AddToBlacklist** | 封禁时 |
| **强制下线** | 用户操作 → **调用 AddToBlacklist** | 触发下线时 |
| **网关校验** | JWT 校验层 → **调用 IsBlacklisted** | 每次请求 |
| **账号解封** | 管理员操作 → **调用 RemoveFromBlacklist** | 解封时 |
---
## 五、文件结构
```
backend/pkg/database/
├── database.go # 现有 PostgreSQL
└── redis.go # 新建Redis 客户端 + Token 黑名单
backend/gateway/config/config.go # 修改:新增 Redis 配置
```
---
## 六、实现步骤
1. 新建 `pkg/database/redis.go`,实现 Redis 客户端初始化 + 黑名单操作
2. 修改 `gateway/config/config.go`,添加 Redis 配置项和环境变量读取
3. 在 gateway 初始化时调用 `database.InitRedis`
4. 在 JWT 中间件中调用 `database.IsBlacklisted` 检查黑名单
5. 在账号封禁/强制下线时调用 `database.AddToBlacklist`
---
## 七、变更记录
| 日期 | 变更内容 |
|------|---------|
| 2026-05-14 | 初始设计 |