forked from alterminal/alterminal
first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user