Files
2026-10-03 10:44:29 +08:00

281 lines
12 KiB
Go
Raw Permalink 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 oidc
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"sort"
"strings"
"time"
"gorm.io/gorm"
"alterminal/internal/auth"
)
// 各種憑證的有效時間:授權碼為一次性短時效憑證(RFC 6749 §4.1.2 建議
// 最長 10 分鐘,取 5 分鐘);Access/ID Token 15 分鐘為業界常見值;
// Refresh Token 30 天,兌換時輪替。
const (
authorizationCodeTTL = 5 * time.Minute
accessTokenTTL = 15 * time.Minute
idTokenTTL = 15 * time.Minute
refreshTokenTTL = 30 * 24 * time.Hour
)
// sha256Token 回傳字串的 SHA-256 雜湊(無填充 base64url,43 字元)。
// 授權碼與 refresh token 本身即高熵亂數,兌換時僅能以憑證值查詢、無
// 其他鍵可用(不同於 client secret 以 client_id 為鍵後再做慢雜湊),
// 故以 SHA-256 作為可索引的確定性雜湊儲存;資料庫外洩時攻擊者亦無法
// 還原明文憑證(RFC 6819 §5.2.2.1 的憑證儲存建議)。
func sha256Token(s string) string {
sum := sha256.Sum256([]byte(s))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// normalizeScope 將空格分隔的 scope 拆解、去重並排序後回傳。比對與
// 儲存皆使用正規化形式,避免「openid profile」與「profile openid」
// 被視為不同集合。
func normalizeScope(scope string) []string {
fields := strings.Fields(scope)
sort.Strings(fields)
seen := make(map[string]bool, len(fields))
out := make([]string, 0, len(fields))
for _, f := range fields {
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out
}
// scopeCovered 回傳 requested 中的每個 scope 皆存在於 granted。
func scopeCovered(granted, requested string) bool {
g := make(map[string]bool)
for _, s := range strings.Fields(granted) {
g[s] = true
}
for _, s := range strings.Fields(requested) {
if !g[s] {
return false
}
}
return true
}
// AuthorizationCode 為授權碼流程的一次性憑證(RFC 6749 §4.1.2),對應
// authorization_codes 資料表。CodeHash 為授權碼明文的 SHA-256,明文僅
// 在發行當下出現於 redirect URI 一次;兌換後設定 UsedAt,之後再次兌換
// 即為重用——除拒絕外並撤銷該碼發行的一切 refresh token。RedirectURI、
// Scope、CodeChallenge 等發行當下的授權內容隨碼凍結,兌換時逐項比對。
type AuthorizationCode struct {
ID uint `gorm:"primaryKey"`
CodeHash string `gorm:"uniqueIndex;size:43;not null"`
ApplicationID uint `gorm:"not null;index"`
UserID uint `gorm:"not null;index"`
RedirectURI string `gorm:"size:2048;not null"`
Scope string `gorm:"size:255;not null"`
Nonce string `gorm:"size:255;not null;default:''"` // OIDC Core §3.1.2.1 nonce,未提供為空
CodeChallenge string `gorm:"size:255;not null;default:''"` // RFC 7636 §4.3 的 challenge(S256),未使用 PKCE 為空
CodeChallengeMethod string `gorm:"size:16;not null;default:''"` // "S256" 或空字串
AuthTime time.Time `gorm:"not null"` // 使用者 Session 建立時間(ID token auth_time 的依據,OIDC Core §2)
ExpiresAt time.Time `gorm:"not null"`
UsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// NewAuthorizationCode 產生並儲存授權碼,回傳模型與明文——明文僅此一次,
// 呼叫方隨即放入 redirect URI,不得留存。authTime 為使用者 Session 的
// 建立時間,隨碼保存供兌換時簽入 ID token。順帶刪除已過期的授權碼
// (最佳清除,失敗不影響發碼)。
func NewAuthorizationCode(db *gorm.DB, applicationID, userID uint, redirectURI, scope, nonce, codeChallenge, codeChallengeMethod string, authTime time.Time) (*AuthorizationCode, string, error) {
code, err := auth.NewToken(32)
if err != nil {
return nil, "", fmt.Errorf("generate code: %w", err)
}
ac := &AuthorizationCode{
CodeHash: sha256Token(code),
ApplicationID: applicationID,
UserID: userID,
RedirectURI: redirectURI,
Scope: scope,
Nonce: nonce,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
AuthTime: authTime,
ExpiresAt: time.Now().Add(authorizationCodeTTL),
}
if err := db.Create(ac).Error; err != nil {
return nil, "", fmt.Errorf("create authorization code: %w", err)
}
db.Where("expires_at < ?", time.Now()).Delete(&AuthorizationCode{})
return ac, code, nil
}
// GetAuthorizationCode 以授權碼明文(雜湊後)查詢對應資料列;查無資料
// 時回傳包裹 gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。
func GetAuthorizationCode(db *gorm.DB, code string) (*AuthorizationCode, error) {
var ac AuthorizationCode
if err := db.Where("code_hash = ?", sha256Token(code)).First(&ac).Error; err != nil {
return nil, fmt.Errorf("query authorization code: %w", err)
}
return &ac, nil
}
// ConsumeAuthorizationCode 以條件更新(used_at 仍為 NULL 且未過期)標記
// 授權碼已兌換,回傳是否成功。條件更新保證並發的第二次兌換必然失敗
// (RFC 6749 §4.1.2 的一次性要求;先查後寫在並發下會有競態)。
func ConsumeAuthorizationCode(db *gorm.DB, id uint) (bool, error) {
now := time.Now()
res := db.Model(&AuthorizationCode{}).
Where("id = ? AND used_at IS NULL AND expires_at > ?", id, now).
Update("used_at", now)
if res.Error != nil {
return false, fmt.Errorf("consume authorization code: %w", res.Error)
}
return res.RowsAffected == 1, nil
}
// RefreshToken 為換發新權杖的長效憑證(RFC 6749 §6),對應
// refresh_tokens 資料表。TokenHash 為明文的 SHA-256。輪替模型為
// 「兌換即作廢舊 token 並發行新 token」(OAuth 2.0 Security BCP
// §4.14.2):RotatedAt 標記已輪替、RevokedAt 標記已撤銷;兌換已輪替
// 的 token 視為重用,撤銷該使用者於該應用程式的全部 refresh token。
// AuthorizationID 記錄發行來源的授權碼,授權碼重用時據此撤銷。
type RefreshToken struct {
ID uint `gorm:"primaryKey"`
TokenHash string `gorm:"uniqueIndex;size:43;not null"`
ApplicationID uint `gorm:"not null;index"`
UserID uint `gorm:"not null;index"`
AuthorizationID uint `gorm:"not null;index"`
Scope string `gorm:"size:255;not null"`
AuthTime time.Time `gorm:"not null"` // 沿用發行來源授權碼的值;來源授權碼到期清除後仍可簽發 ID token
ExpiresAt time.Time `gorm:"not null"`
RotatedAt *time.Time
RevokedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// NewRefreshToken 產生並儲存 refresh token,回傳模型與明文——明文僅在
// token 回應中出現一次。順帶刪除已過期的 refresh token(最佳清除,
// 失敗不影響發行)。
func NewRefreshToken(db *gorm.DB, applicationID, userID, authorizationID uint, scope string, authTime time.Time) (*RefreshToken, string, error) {
token, err := auth.NewToken(32)
if err != nil {
return nil, "", fmt.Errorf("generate refresh token: %w", err)
}
rt := &RefreshToken{
TokenHash: sha256Token(token),
ApplicationID: applicationID,
UserID: userID,
AuthorizationID: authorizationID,
Scope: scope,
AuthTime: authTime,
ExpiresAt: time.Now().Add(refreshTokenTTL),
}
if err := db.Create(rt).Error; err != nil {
return nil, "", fmt.Errorf("create refresh token: %w", err)
}
db.Where("expires_at < ?", time.Now()).Delete(&RefreshToken{})
return rt, token, nil
}
// GetRefreshToken 以 refresh token 明文(雜湊後)查詢對應資料列;查無
// 資料時回傳包裹 gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。
func GetRefreshToken(db *gorm.DB, token string) (*RefreshToken, error) {
var rt RefreshToken
if err := db.Where("token_hash = ?", sha256Token(token)).First(&rt).Error; err != nil {
return nil, fmt.Errorf("query refresh token: %w", err)
}
return &rt, nil
}
// RotateRefreshToken 以條件更新(rotated_at 與 revoked_at 仍為 NULL 且
// 未過期)標記 refresh token 已輪替,回傳是否成功;並發的重複兌換僅
// 一個成功,失敗方即為重用。
func RotateRefreshToken(db *gorm.DB, id uint) (bool, error) {
now := time.Now()
res := db.Model(&RefreshToken{}).
Where("id = ? AND rotated_at IS NULL AND revoked_at IS NULL AND expires_at > ?", id, now).
Update("rotated_at", now)
if res.Error != nil {
return false, fmt.Errorf("rotate refresh token: %w", res.Error)
}
return res.RowsAffected == 1, nil
}
// RevokeRefreshTokensByAuthorization 撤銷指定授權碼發行的所有 refresh
// token(授權碼重用時的防護,RFC 6749 §4.1.2)。
func RevokeRefreshTokensByAuthorization(db *gorm.DB, authorizationID uint) error {
if err := db.Model(&RefreshToken{}).
Where("authorization_id = ? AND revoked_at IS NULL", authorizationID).
Update("revoked_at", time.Now()).Error; err != nil {
return fmt.Errorf("revoke refresh tokens: %w", err)
}
return nil
}
// RevokeRefreshTokensFor 撤銷使用者於指定應用程式的所有 refresh token
// (refresh token 重用偵測時的整鏈撤銷,OAuth 2.0 Security BCP §4.14.2)。
func RevokeRefreshTokensFor(db *gorm.DB, userID, applicationID uint) error {
if err := db.Model(&RefreshToken{}).
Where("user_id = ? AND application_id = ? AND revoked_at IS NULL", userID, applicationID).
Update("revoked_at", time.Now()).Error; err != nil {
return fmt.Errorf("revoke refresh tokens: %w", err)
}
return nil
}
// Consent 為使用者對應用程式的授權同意記錄,對應 consents 資料表:
// 同意頁首次同意後記住 scope 聯集,之後請求的 scope 全部涵蓋於已同意
// 集合時靜默通過,不再顯示同意頁;請求範圍擴大時再次詢問。
type Consent struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null;uniqueIndex:idx_consents_user_application,priority:1"`
ApplicationID uint `gorm:"not null;uniqueIndex:idx_consents_user_application,priority:2"`
Scope string `gorm:"size:255;not null"`
CreatedAt time.Time
UpdatedAt time.Time
}
// GetConsent 查詢使用者對應用程式的同意記錄;查無資料時回傳包裹
// gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。
func GetConsent(db *gorm.DB, userID, applicationID uint) (*Consent, error) {
var c Consent
if err := db.Where("user_id = ? AND application_id = ?", userID, applicationID).First(&c).Error; err != nil {
return nil, fmt.Errorf("query consent: %w", err)
}
return &c, nil
}
// SaveConsent 記錄同意:首次建立,之後將新的 scope 併入既有聯集(同意
// 頁勾選一律代表「允許全部請求的 scope」)。
func SaveConsent(db *gorm.DB, userID, applicationID uint, scope string) error {
var c Consent
err := db.Where("user_id = ? AND application_id = ?", userID, applicationID).First(&c).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
c = Consent{
UserID: userID,
ApplicationID: applicationID,
Scope: strings.Join(normalizeScope(scope), " "),
}
if err := db.Create(&c).Error; err != nil {
return fmt.Errorf("create consent: %w", err)
}
return nil
case err != nil:
return fmt.Errorf("query consent: %w", err)
}
merged := normalizeScope(c.Scope + " " + scope)
c.Scope = strings.Join(merged, " ")
if err := db.Save(&c).Error; err != nil {
return fmt.Errorf("update consent: %w", err)
}
return nil
}