Files
yunzerwebsiteallinone/go/pkg/jwtutil/jwt.go
T
2026-09-20 00:19:08 +08:00

328 lines
9.0 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 jwtutil
import (
"crypto/rsa"
"encoding/base64"
"errors"
"fmt"
"math/big"
"os"
"strings"
"sync"
"time"
beego "github.com/beego/beego/v2/server/web"
"github.com/golang-jwt/jwt/v5"
)
// 算法常量
const (
AlgHS256 = "HS256"
AlgRS256 = "RS256"
)
// DefaultKid 默认密钥 ID。历史 token 未携带 kid,统一按此 ID 处理。
const DefaultKid = "default"
// legacySecret 兼容用的内置密钥:仅用于解析历史已签发的 token。
// 生产环境必须在 app.conf 中配置 jwt_secret,否则会一直使用该固定值。
const legacySecret = "yunzer_jwt_secret_key"
// Claims JWT 载荷。旧字段保持不变,新增字段均为 omitempty,
// 历史 token 解析后为零值,不影响现有 78 处解析点。
type Claims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
TenantId int `json:"tenant_id"` // 租户ID
UserType string `json:"user_type"` // 用户类型:"user" / "employee" / "platform" 等
// ---- 统一认证中心(OIDC)扩展字段 ----
ClientID string `json:"client_id,omitempty"` // 接入应用 client_id,即 aud 的业务标识
Sid string `json:"sid,omitempty"` // 会话ID,用于单点登出/踢下线
Scope string `json:"scope,omitempty"`
Amr string `json:"amr,omitempty"` // 认证方式:pwd/sms/otp/wx/...
// Events OIDC 事件声明,用于 Back-Channel Logout:
// {"http://schemas.openid.net/event/backchannel-logout": {}}
Events map[string]interface{} `json:"events,omitempty"`
jwt.RegisteredClaims
}
// ---------------------------------------------------------------- 密钥管理
var (
keyOnce sync.Once
hsKeys map[string][]byte // kid -> HMAC 密钥
rsaPriv *rsa.PrivateKey // RS256 签名
rsaPub *rsa.PublicKey // RS256 验签
rsaKid string // RS256 密钥 ID
issuerVal string
)
func loadKeys() {
hsKeys = map[string][]byte{DefaultKid: []byte(legacySecret)}
// 主密钥(覆盖内置默认值)
if s, _ := beego.AppConfig.String("jwt_secret"); strings.TrimSpace(s) != "" {
hsKeys[DefaultKid] = []byte(strings.TrimSpace(s))
}
// 轮换密钥:jwt_secrets = kid1:secret1,kid2:secret2
if s, _ := beego.AppConfig.String("jwt_secrets"); strings.TrimSpace(s) != "" {
for _, item := range strings.Split(s, ",") {
kv := strings.SplitN(strings.TrimSpace(item), ":", 2)
if len(kv) == 2 && strings.TrimSpace(kv[0]) != "" && strings.TrimSpace(kv[1]) != "" {
hsKeys[strings.TrimSpace(kv[0])] = []byte(strings.TrimSpace(kv[1]))
}
}
}
// RS256 密钥对:优先读文件路径,其次读内联 PEM
privPEM := readKeyConf("jwt_rsa_private_key", "jwt_rsa_private_key_file")
pubPEM := readKeyConf("jwt_rsa_public_key", "jwt_rsa_public_key_file")
if privPEM != "" {
if k, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privPEM)); err == nil {
rsaPriv = k
rsaPub = &k.PublicKey
}
}
if pubPEM != "" {
if k, err := jwt.ParseRSAPublicKeyFromPEM([]byte(pubPEM)); err == nil {
rsaPub = k
}
}
if rsaKid == "" {
rsaKid, _ = beego.AppConfig.String("jwt_rsa_kid")
}
if rsaKid == "" {
rsaKid = "rsa-1"
}
issuerVal, _ = beego.AppConfig.String("jwt_issuer")
}
func readKeyConf(inlineKey, fileKey string) string {
if v, _ := beego.AppConfig.String(inlineKey); strings.TrimSpace(v) != "" {
return strings.ReplaceAll(strings.TrimSpace(v), `\n`, "\n")
}
if p, _ := beego.AppConfig.String(fileKey); strings.TrimSpace(p) != "" {
if b, err := os.ReadFile(strings.TrimSpace(p)); err == nil {
return string(b)
}
}
return ""
}
func ensureKeys() {
keyOnce.Do(loadKeys)
}
// keyFunc 按 token header 的 alg + kid 选择验签密钥。
// 未携带 kid 或 kid 未注册时回落到默认密钥,保证历史 token 仍可解析。
func keyFunc(token *jwt.Token) (interface{}, error) {
ensureKeys()
kid, _ := token.Header["kid"].(string)
switch token.Method.Alg() {
case AlgHS256:
if sec, ok := hsKeys[kid]; ok && kid != "" {
return sec, nil
}
return hsKeys[DefaultKid], nil
case AlgRS256:
if rsaPub == nil {
return nil, errors.New("服务端未配置 RS256 公钥")
}
return rsaPub, nil
}
return nil, fmt.Errorf("unsupported signing method: %v", token.Header["alg"])
}
// ---------------------------------------------------------------- 签发
// TokenOptions 认证中心签发参数
type TokenOptions struct {
Alg string // HS256 / RS256;留空时优先 RS256,未配置 RSA 则回落 HS256
UserID int // 身份 ID(OIDC sub 用字符串形式)
Username string
TenantID int
UserType string
ClientID string
Sid string
Scope string
Amr string
Subject string
Audience []string
Jti string // JWT ID,用于吊销(登出/踢下线)
Events map[string]interface{} // OIDC 事件声明(Back-Channel Logout 用)
TTL time.Duration // 留空默认 30 分钟
Kid string
}
// SignToken 签发 token。RSA 未配置时自动回落 HS256,保证服务可启动。
func SignToken(opt TokenOptions) (string, error) {
ensureKeys()
alg := strings.ToUpper(strings.TrimSpace(opt.Alg))
if alg == "" {
alg = AlgRS256
}
if alg == AlgRS256 && rsaPriv == nil {
alg = AlgHS256
}
ttl := opt.TTL
if ttl <= 0 {
ttl = 30 * time.Minute
}
now := time.Now()
claims := &Claims{
UserID: opt.UserID,
Username: opt.Username,
TenantId: opt.TenantID,
UserType: opt.UserType,
ClientID: opt.ClientID,
Sid: opt.Sid,
Scope: opt.Scope,
Amr: opt.Amr,
Events: opt.Events,
RegisteredClaims: jwt.RegisteredClaims{
ID: opt.Jti,
Subject: opt.Subject,
Audience: opt.Audience,
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
if issuerVal != "" {
claims.Issuer = issuerVal
}
if opt.Subject == "" && opt.UserID > 0 {
claims.Subject = fmt.Sprintf("%d", opt.UserID)
}
var method jwt.SigningMethod
switch alg {
case AlgRS256:
method = jwt.SigningMethodRS256
default:
method = jwt.SigningMethodHS256
}
token := jwt.NewWithClaims(method, claims)
kid := strings.TrimSpace(opt.Kid)
if kid == "" && alg == AlgRS256 {
kid = rsaKid
}
if kid != "" {
token.Header["kid"] = kid
}
var key interface{}
if alg == AlgRS256 {
key = rsaPriv
} else {
kidToUse := kid
if kidToUse == "" {
kidToUse = DefaultKid
}
if sec, ok := hsKeys[kidToUse]; ok {
key = sec
} else {
key = hsKeys[DefaultKid]
}
}
return token.SignedString(key)
}
// GenerateToken 生成 JWT token(兼容旧签名与行为)。
// 默认 HS256;若 app.conf 配置了 jwt_issuer 则带上 iss。
func GenerateToken(userID int, username string, tenantId int, userType string) (string, error) {
ttl := 24 * time.Hour
ensureKeys()
now := time.Now()
claims := &Claims{
UserID: userID,
Username: username,
TenantId: tenantId,
UserType: userType,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
if issuerVal != "" {
claims.Issuer = issuerVal
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(hsKeys[DefaultKid])
}
// ---------------------------------------------------------------- 解析
// ParseToken 解析并校验 JWT。自动识别 HS256 / RS256,函数签名与旧版一致。
func ParseToken(tokenString string) (*Claims, error) {
claims := &Claims{}
_, err := jwt.ParseWithClaims(tokenString, claims, keyFunc,
jwt.WithValidMethods([]string{AlgHS256, AlgRS256}))
if err != nil {
return nil, err
}
return claims, nil
}
// ParseTokenRaw 解析 token 但不校验有效期,用于登出/吊销场景获取 jti。
func ParseTokenRaw(tokenString string) (*Claims, error) {
claims := &Claims{}
parser := jwt.NewParser(jwt.WithValidMethods([]string{AlgHS256, AlgRS256}), jwt.WithoutClaimsValidation())
if _, err := parser.ParseWithClaims(tokenString, claims, keyFunc); err != nil {
return nil, err
}
return claims, nil
}
// Issuer 返回配置的签发者(未配置时为空)
func Issuer() string {
ensureKeys()
return issuerVal
}
// JWK JSON Web Key(RS256 公钥)
type JWK struct {
Kty string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
}
// JWKS 返回 RSA 公钥集合(供 /auth/jwks.json 暴露)。
// 各应用本地用公钥验签即可,无需每次回调认证中心的 introspect 接口。
func JWKS() []JWK {
ensureKeys()
if rsaPub == nil || rsaPub.N == nil {
return nil
}
return []JWK{{
Kty: "RSA",
Use: "sig",
Alg: AlgRS256,
Kid: rsaKid,
N: base64.RawURLEncoding.EncodeToString(rsaPub.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPub.E)).Bytes()),
}}
}
// HasRSA 是否已配置 RS256 密钥对(决定认证中心能否签发非对称 token)
func HasRSA() bool {
ensureKeys()
return rsaPriv != nil && rsaPub != nil
}
// Kid 返回当前 RS256 密钥 ID
func Kid() string {
ensureKeys()
return rsaKid
}