從 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>
160 lines
4.3 KiB
Go
160 lines
4.3 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// InMemoryPairingStore 是 PairingStore 的記憶體實作,用於 Phase 0 雛形與單元測試。
|
||
//
|
||
// 設計要點:
|
||
// - 以 plaintext token 為 map key(雛形圖簡;Phase 1 的 PostgresPairingStore 會改存 hash)
|
||
// - sync.RWMutex 確保並發安全
|
||
// - 一次性語意:MarkUsed 後 Validate 會回 ErrTokenUsed
|
||
// - TTL 語意:超過 ExpiresAt 後 Validate 回 ErrTokenExpired;CleanupExpired 會移除
|
||
type InMemoryPairingStore struct {
|
||
mu sync.RWMutex
|
||
tokens map[string]*PairingToken // key = plaintext token
|
||
}
|
||
|
||
// NewInMemoryPairingStore 建立一個空的記憶體 PairingStore。
|
||
func NewInMemoryPairingStore() *InMemoryPairingStore {
|
||
return &InMemoryPairingStore{
|
||
tokens: make(map[string]*PairingToken),
|
||
}
|
||
}
|
||
|
||
// Create 產生並保存一個新 pairing token。
|
||
//
|
||
// ttl 為相對存活時間;內部以目前時間 + ttl 算出 ExpiresAt。
|
||
// 若 ttl <= 0,則 ExpiresAt 保持 nil(永不過期;僅測試 / Phase 1 特殊情境使用)。
|
||
func (s *InMemoryPairingStore) Create(
|
||
ctx context.Context, userID string, ttl time.Duration,
|
||
) (string, *PairingToken, error) {
|
||
plaintext, err := GeneratePairingToken()
|
||
if err != nil {
|
||
return "", nil, err
|
||
}
|
||
|
||
now := time.Now().UTC()
|
||
info := &PairingToken{
|
||
Plaintext: plaintext,
|
||
TokenHash: HashToken(plaintext),
|
||
UserID: userID,
|
||
Kind: KindPairing,
|
||
CreatedAt: now,
|
||
}
|
||
if ttl > 0 {
|
||
expires := now.Add(ttl)
|
||
info.ExpiresAt = &expires
|
||
}
|
||
|
||
s.mu.Lock()
|
||
s.tokens[plaintext] = info
|
||
s.mu.Unlock()
|
||
|
||
// 回傳的 info 給 caller 用(不含 Plaintext 避免誤寫入 log)。
|
||
// 但為了讓 caller 能立刻傳給前端顯示一次,Plaintext 保留。
|
||
// 呼叫方有責任不記錄 info.Plaintext 到持久化日誌。
|
||
return plaintext, info, nil
|
||
}
|
||
|
||
// Validate 檢查 token 是否存在且可用(未過期、未消費、未撤銷)。
|
||
func (s *InMemoryPairingStore) Validate(ctx context.Context, token string) (*PairingToken, error) {
|
||
s.mu.RLock()
|
||
info, ok := s.tokens[token]
|
||
s.mu.RUnlock()
|
||
|
||
if !ok {
|
||
return nil, ErrInvalidToken
|
||
}
|
||
if info.IsRevoked() {
|
||
return nil, ErrTokenRevoked
|
||
}
|
||
if info.IsUsed() {
|
||
return nil, ErrTokenUsed
|
||
}
|
||
if info.IsExpired(time.Now().UTC()) {
|
||
return nil, ErrTokenExpired
|
||
}
|
||
// 回傳 copy 避免 caller 誤改內部狀態(map value 是 pointer,複製 struct)。
|
||
cp := *info
|
||
return &cp, nil
|
||
}
|
||
|
||
// MarkUsed 將 token 標記為已消費,並綁定 deviceID。
|
||
//
|
||
// 若 token 不存在回 ErrInvalidToken;若已標記過則為 no-op(冪等)。
|
||
func (s *InMemoryPairingStore) MarkUsed(ctx context.Context, token, deviceID string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
info, ok := s.tokens[token]
|
||
if !ok {
|
||
return ErrInvalidToken
|
||
}
|
||
if info.UsedAt != nil {
|
||
// 已使用 — 冪等回 nil,但不覆寫 deviceID
|
||
return nil
|
||
}
|
||
now := time.Now().UTC()
|
||
info.UsedAt = &now
|
||
info.DeviceID = deviceID
|
||
return nil
|
||
}
|
||
|
||
// Revoke 撤銷一個 token(Validate 後會回 ErrTokenRevoked)。
|
||
func (s *InMemoryPairingStore) Revoke(ctx context.Context, token string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
info, ok := s.tokens[token]
|
||
if !ok {
|
||
return ErrInvalidToken
|
||
}
|
||
if info.RevokedAt != nil {
|
||
return nil // 冪等
|
||
}
|
||
now := time.Now().UTC()
|
||
info.RevokedAt = &now
|
||
return nil
|
||
}
|
||
|
||
// List 回傳指定 user 的所有 pairing token(含已使用 / 撤銷)。
|
||
//
|
||
// 注意:回傳的 slice 為 copy,但 Plaintext 欄位也被複製 — Caller 應避免記錄。
|
||
func (s *InMemoryPairingStore) List(ctx context.Context, userID string) ([]*PairingToken, error) {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
out := make([]*PairingToken, 0)
|
||
for _, info := range s.tokens {
|
||
if info.UserID == userID {
|
||
cp := *info
|
||
out = append(out, &cp)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// CleanupExpired 移除所有已過 ExpiresAt 的 token;回傳移除數量。
|
||
//
|
||
// 通常由 background goroutine 週期性呼叫(例:每 1 分鐘)。
|
||
func (s *InMemoryPairingStore) 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.IsExpired(now) {
|
||
delete(s.tokens, k)
|
||
removed++
|
||
}
|
||
}
|
||
return removed, nil
|
||
}
|
||
|
||
// 編譯時檢查:確保 InMemoryPairingStore 實作 PairingStore。
|
||
var _ PairingStore = (*InMemoryPairingStore)(nil)
|