jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 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>
2026-05-01 11:21:20 +08:00

193 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 機率近乎 02^-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。
//
// idleTimeoutnow - LastSeenAt > idleTimeout
// absoluteTimeoutnow - 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)
}