Files
backend/internal/pkg/jwt/jwt.go
lafay eb931bf1c6
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 46s
feat(auth): implement session-based token management with revocation support
Add Session model, SessionService, and SessionRepository to track user login
sessions and enable token revocation on auth-critical events.

- Introduce explicit TokenType (access/refresh) in JWT claims to prevent
  refresh token misuse via access token endpoints
- Add SessionID field to JWT claims, enabling stateless JWT validation
  against revoked sessions
- Replace legacy Auth middleware with RequireAuth/OptionalAuth pipeline
  that validates token type, account status, and session validity
- Implement session revocation on password change, reset, user ban/inactive,
  and explicit logout
- Add Principal cache with active invalidation for banned/role-changed users
- Fix IDOR vulnerability: GetMessagesByCursor now validates currentUserID
  is conversation participant via GetParticipantStrict
- Add group member visibility checks: announcements, group info, member list
  now require group membership
- Simplify Casbin policy: remove g grouping, use r.sub == p.sub matcher
  with globMatch; user_roles table is single source of truth for roles
- Add migration logic to clean legacy casbin g rules and migrate old p rules
  from path-style to admin/<domain> resource naming
2026-07-05 18:28:08 +08:00

189 lines
5.7 KiB
Go
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.
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/sidParseAccessToken 在过渡期宽容通过;
// 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
}