package jwt import ( "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" ) // 令牌类型常量。引入显式 typ 字段,避免 access/refresh 仅靠 RegisteredClaims.ID 隐式区分。 const ( TokenTypeAccess = "access" TokenTypeRefresh = "refresh" ) var ( ErrInvalidToken = errors.New("invalid token") ErrExpiredToken = errors.New("token has expired") ErrWrongTokenType = errors.New("wrong token type") ErrLegacyToken = errors.New("legacy token without type") ) // Claims JWT 声明 // // 引入 TokenType 与 SessionID 字段,使令牌用途与会话在编译期显式可见: // - TokenType: "access" 或 "refresh",由 ParseAccessToken/ParseRefreshToken 严格校验。 // - SessionID: 关联 sessions 表,支持登出/封禁后的令牌撤销。 // // 旧客户端签发的 token 不含 typ/sid,ParseAccessToken 在过渡期宽容通过; // ParseRefreshToken 始终严格要求 typ=refresh,强制客户端重新登录获取新 refresh token。 type Claims struct { UserID string `json:"sub"` Username string `json:"username"` TokenType string `json:"typ,omitempty"` SessionID string `json:"sid,omitempty"` jwt.RegisteredClaims } // JWT JWT工具 type JWT struct { secretKey string accessTokenExpire time.Duration refreshTokenExpire time.Duration } // New 创建JWT实例 func New(secret string, accessExpire, refreshExpire time.Duration) *JWT { return &JWT{ secretKey: secret, accessTokenExpire: accessExpire, refreshTokenExpire: refreshExpire, } } // GenerateAccessToken 生成访问令牌 // // sessionID 关联 sessions 表用于撤销;空字符串仅在测试或无 session 模型时允许。 func (j *JWT) GenerateAccessToken(userID, username, sessionID string) (string, error) { now := time.Now() claims := Claims{ UserID: userID, Username: username, TokenType: TokenTypeAccess, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(j.accessTokenExpire)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), Issuer: "carrot_bbs", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(j.secretKey)) } // GenerateRefreshToken 生成刷新令牌 // // refresh token 必须携带 sessionID,刷新时用于校验会话有效性并支持轮换撤销。 func (j *JWT) GenerateRefreshToken(userID, username, sessionID string) (string, error) { now := time.Now() claims := Claims{ UserID: userID, Username: username, TokenType: TokenTypeRefresh, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(j.refreshTokenExpire)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), Issuer: "carrot_bbs", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(j.secretKey)) } // parseWithKey 解析 token 字符串为 Claims,仅做签名与过期校验。 func (j *JWT) parseWithKey(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { return []byte(j.secretKey), nil }) if err != nil { return nil, err } if claims, ok := token.Claims.(*Claims); ok && token.Valid { return claims, nil } return nil, ErrInvalidToken } // ParseAccessToken 仅接受 typ=access 的令牌。 // // 过渡兼容:若 typ 为空(旧客户端签发),宽容通过并返回 ErrLegacyToken 包装的错误, // 调用方可据此打告警日志;旧 token 在一个 access TTL 周期内自然过期后即不再被宽容。 // 不接受 refresh token,明确返回 ErrWrongTokenType。 func (j *JWT) ParseAccessToken(tokenString string) (*Claims, error) { claims, err := j.parseWithKey(tokenString) if err != nil { return nil, err } switch claims.TokenType { case TokenTypeAccess: return claims, nil case "": // 旧客户端兼容窗口:旧 token 不含 typ,仅在剩余 TTL 内通过。 return claims, fmt.Errorf("%w: sub=%s", ErrLegacyToken, claims.UserID) default: return nil, ErrWrongTokenType } } // ParseRefreshToken 仅接受 typ=refresh 的令牌,且必须有 SessionID。 // // refresh 端点不接受旧格式 token,强制客户端重新登录获取新 refresh token。 func (j *JWT) ParseRefreshToken(tokenString string) (*Claims, error) { claims, err := j.parseWithKey(tokenString) if err != nil { return nil, err } if claims.TokenType != TokenTypeRefresh { return nil, ErrWrongTokenType } if claims.SessionID == "" { // 防御性:新代码签发的 refresh token 一定带 sid。 return nil, ErrInvalidToken } return claims, nil } // ParseToken 解析令牌(不校验类型) // // Deprecated: 仅供历史调用点逐步迁移。新代码应使用 ParseAccessToken/ParseRefreshToken。 // 当前实现内部调用 ParseAccessToken 以确保 refresh token 不能悄悄通过 Auth 中间件; // 旧格式 access token 仍可被解析(typ 为空时返回 claims 与 ErrLegacyToken)。 func (j *JWT) ParseToken(tokenString string) (*Claims, error) { claims, err := j.parseWithKey(tokenString) if err != nil { return nil, err } // 拒绝 refresh token:避免历史调用点误把它当 access token 使用。 if claims.TokenType == TokenTypeRefresh { return nil, ErrWrongTokenType } return claims, nil } // ValidateToken 验证令牌是否可作为 access token 使用。 // // Deprecated: 仅供历史调用点逐步迁移。新代码应使用 ParseAccessToken。 func (j *JWT) ValidateToken(tokenString string) error { _, err := j.ParseAccessToken(tokenString) if err == nil { return nil } if errors.Is(err, ErrLegacyToken) { // 旧 token 在兼容窗口内视为有效。 return nil } return err }