206 lines
6.1 KiB
Go
206 lines
6.1 KiB
Go
package common
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// JWTClaims defines the payload of the access token.
|
|
type JWTClaims struct {
|
|
UserID int64 `json:"user_id"`
|
|
UserType string `json:"user_type"` // admin / tenant
|
|
Role string `json:"role"`
|
|
TenantID int64 `json:"tenant_id,omitempty"`
|
|
SessionID int64 `json:"session_id"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// TokenPair holds an access token and a refresh token.
|
|
type TokenPair struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
var jwtSecret []byte
|
|
|
|
// InitJWTSecret loads the JWT signing secret from config.
|
|
func InitJWTSecret(ctx context.Context) {
|
|
secret := g.Cfg().MustGet(ctx, "jwt.secret").String()
|
|
if secret == "" {
|
|
panic("jwt.secret is not configured — refusing to start with weak key")
|
|
}
|
|
jwtSecret = []byte(secret)
|
|
}
|
|
|
|
// GetJWTSecret returns the cached JWT signing secret.
|
|
func GetJWTSecret() []byte {
|
|
if jwtSecret == nil {
|
|
panic("JWT secret not initialized — call InitJWTSecret first")
|
|
}
|
|
return jwtSecret
|
|
}
|
|
|
|
// getAccessExpire returns the access token expiration duration.
|
|
func getAccessExpire(ctx context.Context) time.Duration {
|
|
d := g.Cfg().MustGet(ctx, "jwt.accessTokenExpire").String()
|
|
if d == "" {
|
|
return 30 * time.Minute
|
|
}
|
|
dur, err := time.ParseDuration(d)
|
|
if err != nil {
|
|
return 30 * time.Minute
|
|
}
|
|
return dur
|
|
}
|
|
|
|
// getRefreshExpire returns the refresh token expiration duration.
|
|
func getRefreshExpire(ctx context.Context) time.Duration {
|
|
d := g.Cfg().MustGet(ctx, "jwt.refreshTokenExpire").String()
|
|
if d == "" {
|
|
return 7 * 24 * time.Hour
|
|
}
|
|
dur, err := time.ParseDuration(d)
|
|
if err != nil {
|
|
return 7 * 24 * time.Hour
|
|
}
|
|
return dur
|
|
}
|
|
|
|
// getIssuer returns the JWT issuer.
|
|
func getIssuer(ctx context.Context) string {
|
|
return g.Cfg().MustGet(ctx, "jwt.issuer").String()
|
|
}
|
|
|
|
// GenerateTokenPair creates an access token and a refresh token.
|
|
// jti is a UUID that uniquely identifies the session for revocation tracking.
|
|
func GenerateTokenPair(ctx context.Context, userID int64, userType, role string, tenantID, sessionID int64, jti string) (*TokenPair, error) {
|
|
now := time.Now()
|
|
accessExpire := getAccessExpire(ctx)
|
|
issuer := getIssuer(ctx)
|
|
|
|
// Access token
|
|
accessClaims := JWTClaims{
|
|
UserID: userID,
|
|
UserType: userType,
|
|
Role: role,
|
|
TenantID: tenantID,
|
|
SessionID: sessionID,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ID: jti,
|
|
Issuer: issuer,
|
|
Subject: fmt.Sprintf("%d", userID),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(accessExpire)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
NotBefore: jwt.NewNumericDate(now),
|
|
},
|
|
}
|
|
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
|
accessTokenStr, err := accessToken.SignedString(GetJWTSecret())
|
|
if err != nil {
|
|
return nil, gerror.Wrapf(err, "sign access token")
|
|
}
|
|
|
|
// Refresh token (random string, not JWT)
|
|
refreshBytes := make([]byte, 32)
|
|
if _, err := rand.Read(refreshBytes); err != nil {
|
|
return nil, gerror.Wrapf(err, "generate refresh token")
|
|
}
|
|
refreshTokenStr := hex.EncodeToString(refreshBytes)
|
|
|
|
return &TokenPair{
|
|
AccessToken: accessTokenStr,
|
|
RefreshToken: refreshTokenStr,
|
|
ExpiresAt: now.Add(accessExpire),
|
|
}, nil
|
|
}
|
|
|
|
// ParseAccessToken parses and validates an access token.
|
|
func ParseAccessToken(tokenStr string) (*JWTClaims, error) {
|
|
token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (any, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, gerror.Newf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
return GetJWTSecret(), nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
claims, ok := token.Claims.(*JWTClaims)
|
|
if !ok || !token.Valid {
|
|
return nil, gerror.New("invalid token claims")
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// HashRefreshToken returns the SHA-256 hash of a refresh token (for database storage).
|
|
func HashRefreshToken(refreshToken string) string {
|
|
hash := sha256.Sum256([]byte(refreshToken))
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
// GenerateRefreshToken generates a new random refresh token string.
|
|
func GenerateRefreshToken() (string, error) {
|
|
bytes := make([]byte, 32)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", gerror.Wrapf(err, "generate refresh token")
|
|
}
|
|
return hex.EncodeToString(bytes), nil
|
|
}
|
|
|
|
// BindSessionIDToRefreshToken 在刷新令牌尾部附加会话 ID(格式:random.sessionID)。
|
|
// 附加段不参与哈希存储(库中存的是随机段的哈希),仅用于令牌被轮换后仍能定位
|
|
// 所属会话 —— 重放检测的前提。随机段为纯 hex,"." 是无歧义分隔符。
|
|
func BindSessionIDToRefreshToken(token string, sessionID int64) string {
|
|
return fmt.Sprintf("%s.%d", token, sessionID)
|
|
}
|
|
|
|
// SplitRefreshToken 拆出刷新令牌的随机段与内嵌会话 ID。
|
|
// ok=false 表示存量旧格式令牌(纯随机段、无会话 ID),调用方需走兼容路径。
|
|
func SplitRefreshToken(token string) (randomPart string, sessionID int64, ok bool) {
|
|
idx := strings.LastIndex(token, ".")
|
|
if idx <= 0 || idx == len(token)-1 {
|
|
return "", 0, false
|
|
}
|
|
sid, err := strconv.ParseInt(token[idx+1:], 10, 64)
|
|
if err != nil || sid <= 0 {
|
|
return "", 0, false
|
|
}
|
|
return token[:idx], sid, true
|
|
}
|
|
|
|
// HashRefreshTokenForCompare 计算用于与库中哈希比对的值:
|
|
// 新格式令牌哈希随机段,存量令牌哈希全文(历史版本的存储口径)。
|
|
func HashRefreshTokenForCompare(token string) string {
|
|
if randomPart, _, ok := SplitRefreshToken(token); ok {
|
|
return HashRefreshToken(randomPart)
|
|
}
|
|
return HashRefreshToken(token)
|
|
}
|
|
|
|
// GenerateJti generates a new UUID-based JWT ID for session revocation tracking.
|
|
func GenerateJti() string {
|
|
return uuid.New().String()
|
|
}
|
|
|
|
// GetMaxSessions returns the max concurrent sessions for a user type.
|
|
func GetMaxSessions(ctx context.Context, userType string) int {
|
|
if userType == "admin" {
|
|
return g.Cfg().MustGet(ctx, "jwt.adminMaxSessions").Int()
|
|
}
|
|
return g.Cfg().MustGet(ctx, "jwt.tenantMaxSessions").Int()
|
|
}
|