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

161 lines
5.3 KiB
Go
Raw 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 auth
import (
"context"
"sync"
"time"
)
// ==========================================================================
// SessionTokenStore
// ==========================================================================
//
// 對齊 security.md §1.3 / visiona-agent-tdd.md §4.3
// - Pairing TokenvAc_ + 32 hex15 min TTL一次性。
// - Session TokenvAs_ + 64 hex90 天 TTL長期可撤銷。
//
// 本 store 負責「Pairing → Session」交換後發出的 Session Token 生命週期管理。
// 雛形Phase 0以 in-memory map 持有Phase 1 換為 Postgres 時維持介面。
//
// 注意:雛形 remote-proxy 目前只做 token 格式驗證(見 relay/server.go
// isAcceptableToken**不會**實際查 SessionTokenStore。這是刻意的雛形取捨
// 對應 visiona-agent-tdd.md 的「選項 A」。Phase 1 會新增
// `GET /internal/session-token/:token` 讓 remote-proxy 拉驗證。
// SessionTokenTTL 是 Session Token 的預設存活時間(對齊 security.md §1.3)。
const SessionTokenTTL = 90 * 24 * time.Hour
// SessionTokenStore 管理 Session Token 的生命週期。
//
// 實作必須是 goroutine-safe雛形使用 InMemorySessionTokenStore。
type SessionTokenStore interface {
// Create 產生並保存一個新的 Session Token。
//
// ttl 為相對存活時間;若 <= 0 視為「無過期時間」。
// plaintext 為原文 tokencaller 只此一次能拿到)。
Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (plaintext string, info *SessionToken, err error)
// Get 依 plaintext 查詢 Session Tokentoken 不存在回 ErrInvalidToken
// 過期回 ErrTokenExpired已撤銷回 ErrTokenRevoked。
//
// 回傳的 SessionToken 為 copycaller 不可直接改內部狀態。
Get(ctx context.Context, plaintext string) (*SessionToken, error)
// Revoke 撤銷一個 Session Token之後 Get 會回 ErrTokenRevoked。
// 若 token 不存在回 ErrInvalidToken已撤銷為冪等回 nil
Revoke(ctx context.Context, plaintext string) error
// CleanupExpired 移除所有已過期的 token回傳移除數量。
// 由 background goroutine 週期性呼叫;雛形暫無呼叫處。
CleanupExpired(ctx context.Context, now time.Time) (removed int, err error)
}
// ==========================================================================
// InMemorySessionTokenStore
// ==========================================================================
// InMemorySessionTokenStore 是 SessionTokenStore 的雛形記憶體實作。
//
// 設計要點(刻意對齊 InMemoryPairingStore 風格):
// - 以 plaintext token 為 map keyPhase 1 改 hash
// - sync.RWMutex 保護並發存取
// - ExpiresAt 為 nil 代表永不過期
type InMemorySessionTokenStore struct {
mu sync.RWMutex
tokens map[string]*SessionToken // key = plaintext token
}
// NewInMemorySessionTokenStore 建立一個空的記憶體 SessionTokenStore。
func NewInMemorySessionTokenStore() *InMemorySessionTokenStore {
return &InMemorySessionTokenStore{
tokens: make(map[string]*SessionToken),
}
}
// Create 產生並保存一個新 Session Token。
//
// parentTokenHash 為升級來源(通常是 Pairing Token 的 hash方便 Phase 1
// 做稽核追蹤;雛形 caller 傳空字串也可以。
func (s *InMemorySessionTokenStore) Create(
ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration,
) (string, *SessionToken, error) {
plaintext, err := GenerateSessionToken()
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
info := &SessionToken{
Plaintext: plaintext,
TokenHash: HashToken(plaintext),
UserID: userID,
DeviceID: deviceID,
ParentTokenHash: parentTokenHash,
CreatedAt: now,
}
if ttl > 0 {
expires := now.Add(ttl)
info.ExpiresAt = &expires
}
s.mu.Lock()
s.tokens[plaintext] = info
s.mu.Unlock()
return plaintext, info, nil
}
// Get 查詢 Session Token回傳前會檢查過期 / 撤銷狀態。
func (s *InMemorySessionTokenStore) Get(ctx context.Context, plaintext string) (*SessionToken, error) {
s.mu.RLock()
info, ok := s.tokens[plaintext]
s.mu.RUnlock()
if !ok {
return nil, ErrInvalidToken
}
if info.RevokedAt != nil {
return nil, ErrTokenRevoked
}
if info.ExpiresAt != nil && time.Now().UTC().After(*info.ExpiresAt) {
return nil, ErrTokenExpired
}
cp := *info
return &cp, nil
}
// Revoke 撤銷 Session Token之後 Get 會回 ErrTokenRevoked。
func (s *InMemorySessionTokenStore) Revoke(ctx context.Context, plaintext string) error {
s.mu.Lock()
defer s.mu.Unlock()
info, ok := s.tokens[plaintext]
if !ok {
return ErrInvalidToken
}
if info.RevokedAt != nil {
return nil // 冪等
}
now := time.Now().UTC()
info.RevokedAt = &now
return nil
}
// CleanupExpired 移除所有已過期ExpiresAt < now的 token。
func (s *InMemorySessionTokenStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
removed := 0
for k, info := range s.tokens {
if info.ExpiresAt != nil && now.After(*info.ExpiresAt) {
delete(s.tokens, k)
removed++
}
}
return removed, nil
}
// 編譯時檢查:確保 InMemorySessionTokenStore 實作 SessionTokenStore。
var _ SessionTokenStore = (*InMemorySessionTokenStore)(nil)