dcfd24b624
v1.0.3 定位为 bug fix release,收口 v1.0.2 引入的破坏性清理并修复 4 个轻量 bug + 依赖复查 + 版本号治理 + 文档对齐。同时包含此前未提交 的 v1.0.2 全部工作。 v1.0.3 变更: Fixed: - generateJTI 忽略 rand.Read 错误(jwt)— 改为 (string, error) 并传播 - QueryBuilder.Page Count 受残留 Limit 截断(repository)— countDB 加 Limit(-1).Offset(-1) - OSS/本地存储文件名冲突(storage)— 新增 uniqueFilename 加 8 字节随机后缀 - 数据库重试策略对不可恢复错误无效(database)— 新增 isTransientDBError Dependencies: - go mod tidy 补全 postgres 方言传递依赖;安全补丁升级 x/crypto v0.49→v0.53、golang-jwt/jwt/v5 v5.2.1→v5.3.1、gorilla/websocket v1.5.1→v1.5.3 Removed: - Breaking: 清理 v1.0.2 兼容别名(InitMySQL* / driverName) - 死代码 database.DBResolver - 代码中 292 行"评分/理由"自夸注释(#26,23 个文件) Changed: - Breaking: 错误码体系重构 CodeSuccess 1→0、CodeFail 0→1,删除 CodeInvalidParams,加编译期防撞码 - database/mysql.go → manager.go;Logger 拆分三独立 core 修复 Tee 重复写入 - 版本号常量化:app.go 新增 const Version 作为唯一来源,CLI/脚手架模板引用之,不再散落字面量 Security: - CORS 中间件按 W3C 规范修复 Allow-Credentials / Vary: Origin / 非白名单不回显 Added: - console 包显式 level 控制(SetLevel/WithLevel/LevelSilent,atomic.Int32 并发安全) - 新增测试 jwt/repository/storage/database/router/middleware/app - 文档:新增 CHANGELOG.md、Version_v1.0.2_report.md;更新 README/GUIDE 顶部更新日志 - 文档对齐:删除对不存在的 config.DefaultManager() getter 的描述(保持 API 与文档一致) 详见 CHANGELOG.md 与 README.md 更新日志。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
256 lines
6.7 KiB
Go
256 lines
6.7 KiB
Go
package jwt
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||
"github.com/golang-jwt/jwt/v5"
|
||
)
|
||
|
||
// Claims JWT 声明
|
||
type Claims struct {
|
||
UserID uint `json:"user_id"`
|
||
Username string `json:"username"`
|
||
Role string `json:"role"` // admin 或 staff
|
||
UserType string `json:"user_type"` // super_admin, admin, staff
|
||
JTI string `json:"jti"` // JWT ID(唯一标识,用于黑名单)
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
var (
|
||
//ErrTokenExpired 令牌已过期
|
||
ErrTokenExpired = errors.New("令牌已过期")
|
||
//ErrTokenInvalid 令牌无效
|
||
ErrTokenInvalid = errors.New("令牌无效")
|
||
//ErrTokenMalformed 令牌格式错误
|
||
ErrTokenMalformed = errors.New("令牌格式错误")
|
||
//ErrTokenNotValidYet 令牌尚未生效
|
||
ErrTokenNotValidYet = errors.New("令牌尚未生效")
|
||
//ErrTokenRevoked 令牌已被撤销
|
||
ErrTokenRevoked = errors.New("令牌已被撤销")
|
||
)
|
||
|
||
// generateJTI 生成唯一的 JWT ID
|
||
func generateJTI() (string, error) {
|
||
bytes := make([]byte, 16)
|
||
if _, err := rand.Read(bytes); err != nil {
|
||
return "", fmt.Errorf("生成 JTI 失败: %w", err)
|
||
}
|
||
return base64.URLEncoding.EncodeToString(bytes), nil
|
||
}
|
||
|
||
// TokenBlacklist Token 黑名单管理(使用 JTI 优化)
|
||
type TokenBlacklist struct{}
|
||
|
||
// Add 将 Token 的 JTI 加入黑名单
|
||
// 参数: jti JWT ID,expiry Token 过期时间
|
||
func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
|
||
if database.RedisClient == nil {
|
||
// Redis 未启用,跳过黑名单
|
||
return nil
|
||
}
|
||
|
||
ctx := context.Background()
|
||
ttl := time.Until(expiry)
|
||
if ttl <= 0 {
|
||
// Token 已过期,无需加入黑名单
|
||
return nil
|
||
}
|
||
|
||
// 使用 JTI 作为键名(约24字节),而非完整 Token(数百字节)
|
||
key := fmt.Sprintf("jwt_bl:%s", jti)
|
||
return database.RedisClient.Set(ctx, key, "1", ttl).Err()
|
||
}
|
||
|
||
// IsBlacklisted 检查 JTI 是否在黑名单中
|
||
func (tb *TokenBlacklist) IsBlacklisted(jti string) bool {
|
||
if database.RedisClient == nil {
|
||
// Redis 未启用,不检查黑名单
|
||
return false
|
||
}
|
||
|
||
ctx := context.Background()
|
||
key := fmt.Sprintf("jwt_bl:%s", jti)
|
||
return database.RedisClient.Exists(ctx, key).Val() > 0
|
||
}
|
||
|
||
// 全局黑名单实例
|
||
var tokenBlacklist = &TokenBlacklist{}
|
||
|
||
// GenerateToken 生成 JWT Token
|
||
func GenerateToken(userID uint, username, role, userType string) (string, error) {
|
||
cfg := config.Get()
|
||
|
||
// 生成唯一的 JWT ID
|
||
jti, err := generateJTI()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
claims := Claims{
|
||
UserID: userID,
|
||
Username: username,
|
||
Role: role,
|
||
UserType: userType,
|
||
JTI: jti,
|
||
RegisteredClaims: jwt.RegisteredClaims{
|
||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(cfg.JWT.Expire) * time.Second)),
|
||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||
Issuer: "xlgo",
|
||
ID: jti, // 同时设置到 RegisteredClaims.ID
|
||
},
|
||
}
|
||
|
||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||
return token.SignedString([]byte(cfg.JWT.Secret))
|
||
}
|
||
|
||
// GenerateTokenWithCustomExpiry 生成带自定义过期时间的 Token
|
||
func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) {
|
||
cfg := config.Get()
|
||
|
||
jti, err := generateJTI()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
claims := Claims{
|
||
UserID: userID,
|
||
Username: username,
|
||
Role: role,
|
||
UserType: userType,
|
||
JTI: jti,
|
||
RegisteredClaims: jwt.RegisteredClaims{
|
||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireSeconds) * time.Second)),
|
||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||
Issuer: "xlgo",
|
||
ID: jti,
|
||
},
|
||
}
|
||
|
||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||
return token.SignedString([]byte(cfg.JWT.Secret))
|
||
}
|
||
|
||
// ParseToken 解析 JWT Token
|
||
func ParseToken(tokenString string) (*Claims, error) {
|
||
cfg := config.Get()
|
||
|
||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||
return []byte(cfg.JWT.Secret), nil
|
||
})
|
||
|
||
if err != nil {
|
||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||
return nil, ErrTokenExpired
|
||
}
|
||
if errors.Is(err, jwt.ErrTokenMalformed) {
|
||
return nil, ErrTokenMalformed
|
||
}
|
||
if errors.Is(err, jwt.ErrTokenNotValidYet) {
|
||
return nil, ErrTokenNotValidYet
|
||
}
|
||
return nil, ErrTokenInvalid
|
||
}
|
||
|
||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||
// 使用 JTI 检查黑名单(更高效)
|
||
if claims.JTI != "" && tokenBlacklist.IsBlacklisted(claims.JTI) {
|
||
return nil, ErrTokenRevoked
|
||
}
|
||
return claims, nil
|
||
}
|
||
|
||
return nil, ErrTokenInvalid
|
||
}
|
||
|
||
// InvalidateToken 使 Token 失效(加入黑名单)
|
||
func InvalidateToken(tokenString string) error {
|
||
cfg := config.Get()
|
||
|
||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||
return []byte(cfg.JWT.Secret), nil
|
||
})
|
||
|
||
if err != nil {
|
||
// Token 无效或已过期,无需加入黑名单
|
||
return nil
|
||
}
|
||
|
||
if claims, ok := token.Claims.(*Claims); ok {
|
||
if claims.JTI != "" && claims.ExpiresAt != nil {
|
||
return tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// InvalidateTokenByID 直接通过 JTI 使 Token 失效
|
||
// 参数: jti JWT ID,expiry 过期时间
|
||
func InvalidateTokenByID(jti string, expiry time.Time) error {
|
||
return tokenBlacklist.Add(jti, expiry)
|
||
}
|
||
|
||
// RefreshToken 刷新 Token
|
||
func RefreshToken(tokenString string) (string, error) {
|
||
claims, err := ParseToken(tokenString)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// 将旧 Token 加入黑名单
|
||
if claims.JTI != "" && claims.ExpiresAt != nil {
|
||
tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time)
|
||
}
|
||
|
||
return GenerateToken(claims.UserID, claims.Username, claims.Role, claims.UserType)
|
||
}
|
||
|
||
// GetJTI 从 Token 中提取 JTI(不验证签名)
|
||
// 用于需要在验证前获取 JTI 的场景
|
||
func GetJTI(tokenString string) (string, error) {
|
||
token, _, err := jwt.NewParser().ParseUnverified(tokenString, &Claims{})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if claims, ok := token.Claims.(*Claims); ok {
|
||
return claims.JTI, nil
|
||
}
|
||
|
||
return "", ErrTokenInvalid
|
||
}
|
||
|
||
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
|
||
func IsTokenRevoked(jti string) bool {
|
||
return tokenBlacklist.IsBlacklisted(jti)
|
||
}
|
||
|
||
// GetClaimsFromToken 获取 Token 的 Claims(不验证过期)
|
||
// 用于获取已过期 Token 的信息
|
||
func GetClaimsFromToken(tokenString string) (*Claims, error) {
|
||
cfg := config.Get()
|
||
|
||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||
return []byte(cfg.JWT.Secret), nil
|
||
}, jwt.WithoutClaimsValidation())
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if claims, ok := token.Claims.(*Claims); ok {
|
||
return claims, nil
|
||
}
|
||
|
||
return nil, ErrTokenInvalid
|
||
} |