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 }