328 lines
9.0 KiB
Go
328 lines
9.0 KiB
Go
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
|
||
}
|