forked from alterminal/alterminal
217 lines
7.4 KiB
Go
217 lines
7.4 KiB
Go
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
|
||
}
|