package main import ( "crypto/rand" "encoding/base64" "errors" "fmt" "time" "gorm.io/gorm" ) // Session 為使用者瀏覽器 Session(SSO 核心):ID 為加密安全亂數, // 存於 HttpOnly Cookie,效期內使用者再經任何 RP 發起授權請求時 // 無須重新輸入帳密。 type Session struct { ID string `gorm:"primaryKey;size:43"` // 32 bytes 亂數的 base64url UserID uint `gorm:"not null;index"` User User ExpiresAt time.Time `gorm:"not null"` CreatedAt time.Time UpdatedAt time.Time } // sessionTTL 為 Session 有效時間,到期後 Cookie 失效、列為可清除。 const sessionTTL = 24 * time.Hour // ErrSessionExpired 表示 Session 不存在或已過期。 var ErrSessionExpired = errors.New("session 不存在或已過期") // newRandomToken 產生 n bytes 加密安全亂數的 base64url 字串(無填充; // n=32 時為 43 字元),供 Session ID 與 CSRF token 共用。 func newRandomToken(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("read random: %w", err) } return base64.RawURLEncoding.EncodeToString(b), nil } // createSession 為使用者建立新 Session,順帶刪除所有已過期 Session // (最佳清除,失敗不影響登入結果)。 func createSession(db *gorm.DB, userID uint) (*Session, error) { id, err := newRandomToken(32) if err != nil { return nil, err } s := &Session{ID: id, UserID: userID, ExpiresAt: time.Now().Add(sessionTTL)} if err := db.Create(s).Error; err != nil { return nil, fmt.Errorf("create session: %w", err) } db.Where("expires_at < ?", time.Now()).Delete(&Session{}) return s, nil } // deleteSession 以 ID 刪除 Session(登出用)。查無該 Session 不視為 // 錯誤,讓登出維持冪等。 func deleteSession(db *gorm.DB, id string) error { if err := db.Delete(&Session{}, "id = ?", id).Error; err != nil { return fmt.Errorf("delete session: %w", err) } return nil } // getSession 以 ID 查詢效期內的 Session(含所屬使用者)。 func getSession(db *gorm.DB, id string) (*Session, error) { var s Session err := db.Preload("User").Where("id = ? AND expires_at > ?", id, time.Now()).First(&s).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrSessionExpired } if err != nil { return nil, fmt.Errorf("query session: %w", err) } return &s, nil }