從 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>
233 lines
9.3 KiB
Go
233 lines
9.3 KiB
Go
// 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.6(OB5)起,唯一的認證路徑是 OIDC + cookie session(見 internal/oidc/
|
||
// 與 internal/usersession/),因此本 package 不再提供 AuthProvider / AuthService 的
|
||
// 內建實作。介面本身仍保留,供未來新增備援 provider(例:Phase 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 表示一次性 token(pairing)已經被消費。
|
||
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-user;Phase 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 是短期一次性 token(15 min TTL),用於首次 agent 配對。
|
||
KindPairing TokenKind = "pairing"
|
||
|
||
// KindSession 是長期可撤銷 token(90 天 TTL),agent 升級後使用。
|
||
KindSession TokenKind = "session"
|
||
)
|
||
|
||
// PairingToken 代表一個已發行(尚未消費)的 pairing token 紀錄。
|
||
//
|
||
// 格式:vAc_ + 32 hex(共 36 字元);見 security.md §1.3。
|
||
// DB 僅存 TokenHash(sha256 plaintext),原文 token 僅在建立時回傳一次。
|
||
//
|
||
// 雛形 InMemoryPairingStore 存的是明文 token 作為 key,Phase 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 token(Phase 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。
|
||
// 若無法認證,回傳具體錯誤(例:ErrInvalidToken);middleware 應將其轉為 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 使用 InMemoryPairingStore(map + mutex + TTL 清理);
|
||
// Phase 1 改為 PostgresPairingStore 並加入兩階段升級(pairing → session)。
|
||
//
|
||
// 注意一次性使用的語意:MarkUsed 後 Validate 必須失敗。
|
||
type PairingStore interface {
|
||
// Create 產生並保存一個新的 pairing token。
|
||
// plaintext 為原文 token(caller 只此一次能拿到),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)
|
||
}
|