從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:
- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
(tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
- internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
- internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
防 session fixation, OWASP ASVS V3.2.1)
- 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
- 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
- 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
- OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
(AuthStyleInParams 強制 token endpoint 不送 client_secret)
- 預留 ServiceClient* 欄位給未來 client_credentials grant
- 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
(Audit C1:multi-tenant 隔離破口)
- Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
- 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)
驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
5.3 KiB
Go
193 lines
5.3 KiB
Go
package usersession
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// sessionIDBytes 是隨機 session ID 的 byte 數。
|
||
// 32 bytes → base64url 後 43 字元(無 padding),對齊 oidc-tdd.md §5.1 / §6.2 慣例。
|
||
const sessionIDBytes = 32
|
||
|
||
// nowFunc 是時間取得函式,測試可置換。預設 time.Now。
|
||
// 注意:此 package 內所有時間判斷都應透過此函式以方便測試。
|
||
var nowFunc = time.Now
|
||
|
||
// InMemoryStore 是 Store 的 process-local map 實作。
|
||
//
|
||
// 雛形 Phase 0.6 用此實作;backend 重啟即所有使用者重登(內部測試者可接受)。
|
||
// Phase 1 換 RedisStore / DBStore 接同一個 interface 即可,handler 不必改。
|
||
//
|
||
// 並發安全:以 sync.RWMutex 保護 sessions map。
|
||
type InMemoryStore struct {
|
||
mu sync.RWMutex
|
||
sessions map[string]*Session
|
||
}
|
||
|
||
// NewInMemoryStore 建立一個空的 InMemoryStore。
|
||
func NewInMemoryStore() *InMemoryStore {
|
||
return &InMemoryStore{
|
||
sessions: make(map[string]*Session),
|
||
}
|
||
}
|
||
|
||
// generateSessionID 產生 32 bytes 隨機值,base64url(無 padding)編碼。
|
||
// 使用 crypto/rand,失敗回 error(系統熵源不足才會發生)。
|
||
func generateSessionID() (string, error) {
|
||
b := make([]byte, sessionIDBytes)
|
||
if _, err := rand.Read(b); err != nil {
|
||
return "", err
|
||
}
|
||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||
}
|
||
|
||
// Create 實作 Store.Create。產生隨機 ID 並寫入 store。
|
||
//
|
||
// 不接受 context 取消(map 寫入是同步、瞬時操作),但保留 context 參數以便未來換 Redis。
|
||
func (s *InMemoryStore) Create(ctx context.Context) (*Session, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
id, err := generateSessionID()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := nowFunc()
|
||
sess := &Session{
|
||
ID: id,
|
||
CreatedAt: now,
|
||
LastSeenAt: now,
|
||
}
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
// 理論上 32 bytes random 撞 ID 機率近乎 0(2^-256),保險檢查避免覆蓋。
|
||
if _, exists := s.sessions[id]; exists {
|
||
return nil, ErrInvalidConfig // 用 ErrInvalidConfig 不準確;視為「天文事件」當 internal error 處理
|
||
}
|
||
s.sessions[id] = sess
|
||
|
||
// 回傳副本,避免 caller 直接改到 store 內的 pointer state(必須走 Update)。
|
||
return s.copySession(sess), nil
|
||
}
|
||
|
||
// Get 實作 Store.Get。
|
||
//
|
||
// 找不到回 ErrNoSession。**不**自動更新 LastSeenAt — 由 caller 決定。
|
||
// 回傳的是 store 內 session 的「副本」,避免外部 mutation race。
|
||
func (s *InMemoryStore) Get(ctx context.Context, id string) (*Session, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
if id == "" {
|
||
return nil, ErrNoSession
|
||
}
|
||
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
sess, ok := s.sessions[id]
|
||
if !ok {
|
||
return nil, ErrNoSession
|
||
}
|
||
return s.copySession(sess), nil
|
||
}
|
||
|
||
// Update 實作 Store.Update。
|
||
//
|
||
// 將 caller 修改過的 Session 寫回 store 並把 LastSeenAt 設為 now。
|
||
// 找不到 ID 對應的 session 回 ErrNoSession(不會「順便建立」,避免被當成 Create 用)。
|
||
func (s *InMemoryStore) Update(ctx context.Context, sess *Session) error {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
if sess == nil || sess.ID == "" {
|
||
return ErrNoSession
|
||
}
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
if _, ok := s.sessions[sess.ID]; !ok {
|
||
return ErrNoSession
|
||
}
|
||
|
||
// 寫入 store 一份新的副本(CreatedAt 保留 caller 的值;caller 不應該改它,但即使改了我們不防)
|
||
stored := s.copySession(sess)
|
||
stored.LastSeenAt = nowFunc()
|
||
s.sessions[sess.ID] = stored
|
||
|
||
// 把更新後的 LastSeenAt 反映回 caller pointer(讓 caller 拿到最新值,不必再 Get)
|
||
sess.LastSeenAt = stored.LastSeenAt
|
||
return nil
|
||
}
|
||
|
||
// Delete 實作 Store.Delete。不存在為 no-op。
|
||
func (s *InMemoryStore) Delete(ctx context.Context, id string) error {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
if id == "" {
|
||
return nil
|
||
}
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
delete(s.sessions, id)
|
||
return nil
|
||
}
|
||
|
||
// CleanupExpired 實作 Store.CleanupExpired。
|
||
//
|
||
// idleTimeout:now - LastSeenAt > idleTimeout
|
||
// absoluteTimeout:now - CreatedAt > absoluteTimeout
|
||
// 任一成立即移除。
|
||
//
|
||
// 注意:使用 > 而非 >=,避免邊界閃爍(剛好等於 timeout 時不刪)。
|
||
func (s *InMemoryStore) CleanupExpired(ctx context.Context, idleTimeout, absoluteTimeout time.Duration) (int, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return 0, err
|
||
}
|
||
now := nowFunc()
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
removed := 0
|
||
for id, sess := range s.sessions {
|
||
idleExpired := idleTimeout > 0 && now.Sub(sess.LastSeenAt) > idleTimeout
|
||
absoluteExpired := absoluteTimeout > 0 && now.Sub(sess.CreatedAt) > absoluteTimeout
|
||
if idleExpired || absoluteExpired {
|
||
delete(s.sessions, id)
|
||
removed++
|
||
}
|
||
}
|
||
return removed, nil
|
||
}
|
||
|
||
// copySession 製作 Session 的淺副本(Extra map 也複製一層,避免外部修改)。
|
||
//
|
||
// 必須在持鎖下呼叫,或 caller 確認 src 不被併發修改。
|
||
func (s *InMemoryStore) copySession(src *Session) *Session {
|
||
dst := *src
|
||
if src.Extra != nil {
|
||
dst.Extra = make(map[string]any, len(src.Extra))
|
||
for k, v := range src.Extra {
|
||
dst.Extra[k] = v
|
||
}
|
||
}
|
||
return &dst
|
||
}
|
||
|
||
// Len 回傳當前 session 數量(測試 / 觀測用)。
|
||
func (s *InMemoryStore) Len() int {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return len(s.sessions)
|
||
}
|