從 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>
161 lines
5.3 KiB
Go
161 lines
5.3 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// ==========================================================================
|
||
// SessionTokenStore
|
||
// ==========================================================================
|
||
//
|
||
// 對齊 security.md §1.3 / visiona-agent-tdd.md §4.3:
|
||
// - Pairing Token(vAc_ + 32 hex)15 min TTL,一次性。
|
||
// - Session Token(vAs_ + 64 hex)90 天 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 為原文 token(caller 只此一次能拿到)。
|
||
Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (plaintext string, info *SessionToken, err error)
|
||
|
||
// Get 依 plaintext 查詢 Session Token;token 不存在回 ErrInvalidToken,
|
||
// 過期回 ErrTokenExpired,已撤銷回 ErrTokenRevoked。
|
||
//
|
||
// 回傳的 SessionToken 為 copy,caller 不可直接改內部狀態。
|
||
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 key(Phase 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)
|