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