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

233 lines
9.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 auth 定義 visionA-backend 的雙層 Auth 介面AuthService / AuthProvider
// 以及 Pairing / Session Token 的型別與 Store。
//
// 介面設計對齊 TDD §2.2、security.md §2.0 與 PRD interface-contracts.md §8.2。
//
// 從 Phase 0.6OB5唯一的認證路徑是 OIDC + cookie session見 internal/oidc/
// 與 internal/usersession/),因此本 package 不再提供 AuthProvider / AuthService 的
// 內建實作。介面本身仍保留,供未來新增備援 providerPhase 1 接 backup local auth、
// service-to-service token時直接套用不必重新發明 contract。
//
// 本 package 仍負責提供:
// - UserContext 等領域型別
// - PairingToken / SessionToken 結構與其 Store 介面
// - PairingStore / SessionTokenStore 的 in-memory 實作
package auth
import (
"context"
"errors"
"net/http"
"time"
)
// ==========================================================================
// 錯誤型別(公開 sentinel errors便於 caller 用 errors.Is 比對)
// ==========================================================================
var (
// ErrNotImplemented 用於雛形 stub表示此功能 Phase 0 尚未實作。
ErrNotImplemented = errors.New("auth: not implemented in phase 0")
// ErrInvalidToken 表示 token 格式錯誤、過期、或不被此 provider 認識。
ErrInvalidToken = errors.New("auth: invalid token")
// ErrTokenExpired 表示 token 過了 ExpiresAt。
ErrTokenExpired = errors.New("auth: token expired")
// ErrTokenUsed 表示一次性 tokenpairing已經被消費。
ErrTokenUsed = errors.New("auth: token already used")
// ErrTokenRevoked 表示 token 已被使用者或系統撤銷。
ErrTokenRevoked = errors.New("auth: token revoked")
// ErrInvalidCredentials 表示 email / password 比對失敗Phase 1 實作)。
ErrInvalidCredentials = errors.New("auth: invalid credentials")
// ErrUserNotFound 表示查詢的 user 不存在。
ErrUserNotFound = errors.New("auth: user not found")
// ErrUserAlreadyExists 表示註冊時 email 已存在Phase 1 實作)。
ErrUserAlreadyExists = errors.New("auth: user already exists")
)
// ==========================================================================
// Domain types
// ==========================================================================
// User 是 Auth 系統觀察到的使用者。雛形固定為 demo-userPhase 1 對接真實 DB。
//
// 註:完整 User struct 定義於 database.md §2.1;這裡保留 auth 層所需欄位即可。
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// UserContext 是從 request 解析出來、後續 handler 可信賴的使用者身分資訊。
//
// Middleware 層AuthService.Authenticate負責產生此 context
// Handler 不需知道 token 從哪來,只需讀 UserContext。
type UserContext struct {
UserID string `json:"userId"`
Email string `json:"email,omitempty"`
Roles []string `json:"roles,omitempty"`
OrgID string `json:"orgId,omitempty"`
}
// LoginRequest 是 Login 的輸入參數。
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginResult 是 Login 成功後回傳的完整資訊。
type LoginResult struct {
User *User `json:"user"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken,omitempty"`
ExpiresAt time.Time `json:"expiresAt"`
}
// RegisterRequest 是 Register 的輸入參數。
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name,omitempty"`
}
// ==========================================================================
// Token types
// ==========================================================================
// TokenKind 區分 token 的生命週期類型。
type TokenKind string
const (
// KindPairing 是短期一次性 token15 min TTL用於首次 agent 配對。
KindPairing TokenKind = "pairing"
// KindSession 是長期可撤銷 token90 天 TTLagent 升級後使用。
KindSession TokenKind = "session"
)
// PairingToken 代表一個已發行(尚未消費)的 pairing token 紀錄。
//
// 格式vAc_ + 32 hex共 36 字元);見 security.md §1.3。
// DB 僅存 TokenHashsha256 plaintext原文 token 僅在建立時回傳一次。
//
// 雛形 InMemoryPairingStore 存的是明文 token 作為 keyPhase 1 改為 hash。
type PairingToken struct {
// Plaintext 是原文 token僅在建立時回傳給 caller儲存層請改存 hash
Plaintext string `json:"-"`
// TokenHash 是 sha256(Plaintext) 的 hex 表示DB 實際 PK。
TokenHash string `json:"-"`
UserID string `json:"userId"`
DeviceID string `json:"deviceId,omitempty"`
Kind TokenKind `json:"kind"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
UsedAt *time.Time `json:"usedAt,omitempty"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
}
// IsExpired 回報此 token 是否已過 ExpiresAt。
// ExpiresAt 為 nil 代表永不過期(僅 Phase 1 的 session token 可能如此設定)。
func (t *PairingToken) IsExpired(now time.Time) bool {
if t.ExpiresAt == nil {
return false
}
return now.After(*t.ExpiresAt)
}
// IsUsed 回報此 token 是否已被消費(一次性 pairing token 用)。
func (t *PairingToken) IsUsed() bool {
return t.UsedAt != nil
}
// IsRevoked 回報此 token 是否已撤銷。
func (t *PairingToken) IsRevoked() bool {
return t.RevokedAt != nil
}
// SessionToken 代表升級後的長期 tunnel session tokenPhase 1 使用)。
//
// 格式vAs_ + 64 hex共 68 字元);見 security.md §1.3。
// 雛形階段以單階段 pairing token 代替,故 SessionToken struct 目前主要作為型別佔位。
type SessionToken struct {
Plaintext string `json:"-"`
TokenHash string `json:"-"`
UserID string `json:"userId"`
DeviceID string `json:"deviceId"`
ParentTokenHash string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
}
// ==========================================================================
// Interfaces
// ==========================================================================
// AuthService 是 middleware 層介面:每個 HTTP request 進來時由它解析身分。
//
// 實作時通常 wrap 一個 AuthProvider 或直接讀 cookie / header。
// 雛形 StaticAuthService 永遠回 demo-user方便前端開發時不需要真正登入。
type AuthService interface {
// Authenticate 從 HTTP request 解析出 UserContext。
// 若無法認證回傳具體錯誤ErrInvalidTokenmiddleware 應將其轉為 401。
Authenticate(ctx context.Context, r *http.Request) (*UserContext, error)
// Authorize 判斷此 UserContext 是否有權對 resource 做 action。
// 雛形回 nil全放Phase 1 以 RBAC 實作。
Authorize(ctx context.Context, uc *UserContext, resource, action string) error
}
// AuthProvider 是 handler 層介面:處理登入 / 註冊 / 登出 / token 驗證等明確動作。
//
// 此介面對齊 PRD interface-contracts.md §8.2。雛形以 StaticAuthProvider 填入,
// Phase 1 換為 JWTAuthProvider綁 DB + JWT 簽章)不影響呼叫端。
type AuthProvider interface {
Register(ctx context.Context, req *RegisterRequest) (*User, error)
Login(ctx context.Context, req *LoginRequest) (*LoginResult, error)
Logout(ctx context.Context, token string) error
ValidateToken(ctx context.Context, token string) (*UserContext, error)
GetUser(ctx context.Context, userID string) (*User, error)
}
// PairingStore 管理 pairing token 的生命週期。
//
// Phase 0 使用 InMemoryPairingStoremap + mutex + TTL 清理);
// Phase 1 改為 PostgresPairingStore 並加入兩階段升級pairing → session
//
// 注意一次性使用的語意MarkUsed 後 Validate 必須失敗。
type PairingStore interface {
// Create 產生並保存一個新的 pairing token。
// plaintext 為原文 tokencaller 只此一次能拿到info 為儲存層表示err 為產生錯誤。
Create(ctx context.Context, userID string, ttl time.Duration) (plaintext string, info *PairingToken, err error)
// Validate 檢查 token 是否有效(存在、未過期、未被使用、未被撤銷)。
// 驗證通過回傳 token 資訊否則回具體錯誤ErrInvalidToken / ErrTokenExpired / ...)。
Validate(ctx context.Context, token string) (*PairingToken, error)
// MarkUsed 標記一次性 token 為已使用;之後 Validate 必須失敗。
MarkUsed(ctx context.Context, token string, deviceID string) error
// Revoke 撤銷一個 token使用者操作或系統判定
Revoke(ctx context.Context, token string) error
// List 列出某使用者的所有 pairing token含已使用 / 已撤銷,供 UI 顯示)。
List(ctx context.Context, userID string) ([]*PairingToken, error)
// CleanupExpired 清除超過 ExpiresAt 的 token
// 由 background goroutine 週期性呼叫(建議每分鐘)。
// 回傳被移除的數量,便於觀測。
CleanupExpired(ctx context.Context, now time.Time) (removed int, err error)
}