first commit

This commit is contained in:
2026-10-03 10:44:29 +08:00
parent f373cb8d37
commit bcf3d3769c
58 changed files with 4313 additions and 487 deletions
+216
View File
@@ -0,0 +1,216 @@
package oidc
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"alterminal/internal/application"
"alterminal/internal/auth"
"alterminal/internal/jwk"
)
// jwtHeader 為 JWT 的 protected header(RFC 7515 §4.1;alg 固定 RS256,
// kid 對應 JWKS 的金鑰識別碼,typ 標示為 JWT)。
type jwtHeader struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
// AccessTokenClaims 為 Access Token(JWT profile,RFC 9068)的 claims:
// 自包含、不落庫,資源端點(/userinfo)以 JWKS 驗證簽章與效期。Sub 為
// 使用者 ID 的字串形式(OIDC Core §2 的 sub claim),Aud 為 client_id。
type AccessTokenClaims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
Scope string `json:"scope,omitempty"`
ClientID string `json:"client_id,omitempty"`
}
// idTokenClaims 為 ID Token 的 claims(OIDC Core §2)。Name/Email 等
// 個人資料 claim 僅在授權 scope 含對應值時加入;Nonce 回填授權請求的
// 原值供 RP 綁結(OIDC Core §3.1.3.7.4),AuthTime 為使用者本次
// Session 的建立時間(§2 的 auth_time claim,單位秒)。
type idTokenClaims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
AuthTime int64 `json:"auth_time,omitempty"`
Nonce string `json:"nonce,omitempty"`
Name string `json:"name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"`
}
// ErrInvalidToken 表示 Access Token 無效(格式、簽章、金鑰或效期不符)。
var ErrInvalidToken = errors.New("access token 無效")
// signJWT 以金鑰簽發 RS256 JWT:header 與 claims 各自 JSON 序列化為
// 無填充 base64url,再對兩段連結值以 RSASSA-PKCS1-v1_5 + SHA-256
// 簽章(RFC 7518 §3.3),輸出 header.payload.signature 三段。
func signJWT(key *jwk.SigningKey, claims any) (string, error) {
priv, err := key.PrivateKey()
if err != nil {
return "", fmt.Errorf("parse signing key: %w", err)
}
header, err := json.Marshal(jwtHeader{Alg: jwk.AlgRS256, Kid: key.Kid, Typ: "JWT"})
if err != nil {
return "", fmt.Errorf("marshal jwt header: %w", err)
}
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal jwt claims: %w", err)
}
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload)
digest := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(nil, priv, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("sign jwt: %w", err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
// currentSigningKey 取最新的使用中簽章金鑰供簽發(輪替時新金鑰在前)。
func currentSigningKey(db *gorm.DB) (*jwk.SigningKey, error) {
var k jwk.SigningKey
if err := db.Where("retired_at IS NULL").Order("created_at DESC").First(&k).Error; err != nil {
return nil, fmt.Errorf("query signing key: %w", err)
}
return &k, nil
}
// subject 為使用者的 sub claim 值:使用者 ID 的十進位字串(OIDC Core
// §2 要求 sub 在 issuer 範圍內穩定且唯一)。
func subject(userID uint) string {
return fmt.Sprintf("%d", userID)
}
// IssueAccessToken 簽發 Access Token(效期 15 分鐘)。
func IssueAccessToken(db *gorm.DB, issuer string, userID uint, app *application.Application, scope string) (string, error) {
key, err := currentSigningKey(db)
if err != nil {
return "", err
}
now := time.Now()
return signJWT(key, AccessTokenClaims{
Iss: issuer,
Sub: subject(userID),
Aud: app.ClientID,
Exp: now.Add(accessTokenTTL).Unix(),
Iat: now.Unix(),
Scope: scope,
ClientID: app.ClientID,
})
}
// IssueIDToken 簽發 ID Token(效期 15 分鐘)。authTime 為使用者
// Session 的建立時間;nonce 為授權請求攜帶的原值(無則空)。個人資料
// claim 依授權 scope 決定(profile:name、preferred_username;email:
// email、email_verified——OIDC Core §5.4)。
func IssueIDToken(db *gorm.DB, issuer string, u *auth.User, app *application.Application, scope, nonce string, authTime time.Time) (string, error) {
key, err := currentSigningKey(db)
if err != nil {
return "", err
}
now := time.Now()
claims := idTokenClaims{
Iss: issuer,
Sub: subject(u.ID),
Aud: app.ClientID,
Exp: now.Add(idTokenTTL).Unix(),
Iat: now.Unix(),
AuthTime: authTime.Unix(),
Nonce: nonce,
}
if scopeHas(scope, "profile") {
claims.Name = u.Name
claims.PreferredUsername = u.Username
}
if scopeHas(scope, "email") {
claims.Email = u.Email
verified := u.EmailVerified
claims.EmailVerified = &verified
}
return signJWT(key, claims)
}
// scopeHas 回傳 scope 集合是否包含 s。
func scopeHas(scope, s string) bool {
for _, f := range strings.Fields(scope) {
if f == s {
return true
}
}
return false
}
// VerifyAccessToken 驗證 Access Token 並回傳其 claims:拆解三段 JWT、
// 拒絕非 RS256 的 alg(RFC 8725 §3.4 的演算法混淆防護)、以 header kid
// 對應的簽章金鑰驗章(金鑰輪替過渡期仍可查得已退休金鑰)、比對 issuer
// 與效期(OIDC Core §3.1.3.7 的 iss/exp 驗證項)。任何一項不符即回
// ErrInvalidToken,不洩漏細節。
func VerifyAccessToken(db *gorm.DB, issuer, token string) (*AccessTokenClaims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "結構")
}
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "header")
}
var h jwtHeader
if err := json.Unmarshal(headerJSON, &h); err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "header")
}
if h.Alg != jwk.AlgRS256 || h.Kid == "" {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "alg")
}
var key jwk.SigningKey
if err := db.Where("kid = ?", h.Kid).First(&key).Error; err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "kid")
}
priv, err := key.PrivateKey()
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "kid")
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "signature")
}
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(&priv.PublicKey, crypto.SHA256, digest[:], sig); err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "signature")
}
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims")
}
var claims AccessTokenClaims
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims")
}
if claims.Iss != issuer {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "issuer")
}
if claims.Exp <= time.Now().Unix() {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "exp")
}
return &claims, nil
}