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
+280
View File
@@ -0,0 +1,280 @@
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
}