# 系統設計草案（Member Center）

日期：2026-01-30

## 1. 需求摘要（已確認）
1) 多個網站共用的會員登入中心（SSO）
2) 電子報訂閱管理（發送另建）
3) 未註冊會員可訂閱；註冊後沿用訂閱資料
4) 未註冊會員可取消訂閱（單一清單退訂）
5) 登入需支援 API 與 Redirect 兩種方式（OAuth2 + OIDC）

## 2. 架構原則
- OAuth2 + OIDC（Authorization Code + PKCE）
- 會員中心只管理 Email 與訂閱狀態
- 檔案存取授權沿用 OAuth2/JWT/JWKS，並以 scope + claim 做資源邊界控制
- 對外資料 API 以 service API 為主，授權完全由 scope 控制
- Double Opt-in
- 各站自行設計 UI，主要走 API；少數狀況使用 redirect
- 多租戶為邏輯隔離，但會員資料跨站共享
- 公開訂閱端點必須使用 `list_id + email` 做資料邊界，禁止僅以 `email` 查詢或操作
- 訂閱狀態同步採 webhook（event payload）；queue 可作為未來擴充選項
- PostgreSQL
- 實作：C# .NET Core + MVC + OpenIddict

## 3. 使用者故事（精簡）
- 訪客：在任一站點未登入狀態下輸入 Email 訂閱電子報
- 訪客：在信件中點擊連結完成 double opt-in
- 訪客：點擊取消訂閱連結即可退訂（單一清單）
- 會員：在任一站點登入後，其他站點可無痛登入（SSO）
- 站點後台：可管理站點資訊、訂閱清單、會員基本資料

## 4. 核心模組
- Identity Service：註冊、登入、修改密碼、密碼重設、Email 驗證
- OAuth2/OIDC Service：授權流程、token 發放、ID Token
- Auth Resource Registry：管理外部服務 resource / audience / scopes / client usage 對應
- Profile Service：會員基本資料、聯絡方式、公司資料、地址簿
- Subscription Service：訂閱/退訂/偏好管理
- Admin Console：租戶與清單管理
- Mailer Integration：驗證信/退訂信/確認信的發送介面（外部系統）
- Event Publisher：訂閱事件發佈（供發信系統或數據系統消費）

## 5. 資料模型（概念）
- tenants
  - id, name, domains, status, created_at
- users (ASP.NET Core Identity)
  - id, user_name, email, password_hash, email_confirmed, lockout, is_blacklisted, blacklisted_at, blacklisted_by, created_at, last_login_at, last_seen_at, disabled_at, disabled_by
- user_profiles
  - user_id, last_name, first_name, nick_name, mobile_phone, landline_phone, date_of_birth, gender, company_name, department, job_title, company_phone, tax_id, invoice_title, remark, updated_at
- user_addresses
  - id, user_id, label, recipient_name, recipient_phone, country_code, postal_code, state_region, city, district, address_line1, address_line2, company_name, usage, is_default, address_meta_json, created_at, updated_at

資料約束建議：
- `user_profiles.first_name`、`user_profiles.last_name` 必填
- `user_profiles.gender` 使用列舉：`male` / `female` / `other` / `unspecified`
- `user_profiles.date_of_birth` 使用 `date`
- `user_addresses.country_code` 使用 ISO 3166-1 alpha-2
- `user_addresses.address_line1` 必填
- `user_addresses.usage` 使用列舉：`shipping` / `billing` / `both`
- 同一個 `user_id + usage` 只允許一筆預設地址
- 地址至少保留一筆，禁止刪除最後一筆地址
- `address_meta_json` 作為國家特有欄位補充，不取代主結構欄位
- roles / user_roles (Identity)
  - id, name, created_at
- OpenIddictApplications
  - id, client_id, client_secret, display_name, permissions, redirect_uris, properties（含 `tenant_id`, `usage=tenant_api|send_api|web_login|webhook_outbound|platform_service|file_api`）
- OpenIddictAuthorizations
  - id, application_id, status, subject, type, scopes
- OpenIddictTokens
  - id, application_id, authorization_id, subject, type, status, expiration_date
- OpenIddictScopes
  - id, name, display_name, resources
- newsletter_lists
  - id, tenant_id, name, status, created_at
- newsletter_subscriptions
  - id, list_id, email, user_id (nullable), status, preferences, created_at
- email_blacklist
  - id, email, reason, blacklisted_at, blacklisted_by
- email_verifications
  - id, email, tenant_id, token_hash, purpose, expires_at, consumed_at
- unsubscribe_tokens
  - id, subscription_id, token_hash, expires_at, consumed_at
- file_access_download_tokens
  - id, token_hash, tenant_id, user_id, file_id, object_key, method, scope, issued_by_client_id, expires_at, revoked_at, last_validated_at, created_at
- audit_logs
  - id, actor_type, actor_id, action, payload, created_at
- system_flags
  - id, key, value, updated_at

關聯說明：
- newsletter_subscriptions.email 與 users.email 維持唯一性關聯
- 使用者註冊時，如 email 存在訂閱紀錄，補上 user_id
- 單一清單退訂：unsubscribe token 綁定 subscription_id
- blacklist 記錄於 email_blacklist（全租戶共用）
- email 為會員與訂閱領域的對外主 key，不提供改 email 流程

## 6. 核心流程

### 6.1 OAuth2/OIDC Redirect 登入（Authorization Code + PKCE）
狀態：已支援 `usage=web_login`。

1) 站點建立 OAuth client，`usage=web_login`，設定 `redirect_uris`
2) 站點導向 `/oauth/authorize`，帶 `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method=S256`, `response_type=code`, `scope=openid email profile`
3) 若使用者尚未登入，`/oauth/authorize` 會導向會員中心 Web login，登入後回到原 authorize request
4) 成功後導回 `redirect_uri` 並附 `code`
5) 站點以 `code` + `code_verifier` 向 `/oauth/token` 換取 token

實作註記：
- API 與 Web 需共用 DataProtection application name `MemberCenter`，使 API authorize endpoint 可讀取 Web login cookie。
- 若 API 與 Web 位於不同子網域，需設定 `Auth:CookieDomain`，例如 `.example.com`。
- 若 API 與 Web 不同 origin，Web login 僅允許導回 `Auth:Issuer` 或 `Auth:AllowedLoginReturnUrlPrefixes` 內的 return URL。
- Login / Logout return URL allowlist 分開驗證，採 URI origin 與 path segment prefix 比對，不使用原始字串 `StartsWith`。
- API 可用 `Auth:WebLoginUrl` 指定登入頁位置；預設為 `/account/login`。
- `web_login` 可使用 public client + PKCE，不要求 client secret。
- `web_login` client 可使用 `openid email profile` 與 current-user `profile:*` scopes；這些 scopes 僅能操作 access token subject 自己的資料。

### 6.2 OAuth2 API 使用（站點自行 UI）
1) 站點以 API 驗證使用者登入（會員中心提供 login API）
2) 成功後取得 token（含 ID Token 可選）
3) 站點以 access_token 呼叫其他會員中心 API

### 6.3 未登入狀態的訂閱流程（在獨立平台）
1) 使用者在各站點輸入 Email 並選擇訂閱清單
2) 站點呼叫 `POST /newsletter/subscribe`
3) 會員中心建立 `pending` 訂閱並發送驗證信（透過外部發信系統）
4) 使用者點擊信件連結 `/newsletter/confirm?token=...`
5) 訂閱狀態改為 `active`
6) 會員中心發出事件 `subscription.activated` 到 event/queue

### 6.4 未登入退訂（單一清單）
1) 信件提供「一鍵退訂」連結 `/newsletter/unsubscribe?token=...`
2) 驗證 token 後將該訂閱標記為 `unsubscribed`
3) 會員中心發出事件 `subscription.unsubscribed` 到 event/queue

### 6.4b Send Engine 發信前申請 One-Click Token
1) Send Engine 依收件者呼叫 `POST /newsletter/one-click-unsubscribe-token`，或批次呼叫 `POST /newsletter/one-click-unsubscribe-tokens`
2) body 帶 `tenant_id + list_id + subscriber_id`（批次版為 `subscriber_ids[]`）
3) 會員中心簽發 token（與手動退訂 token 分離 purpose）
4) Send Engine 將 token 寫入 `List-Unsubscribe` 連結

### 6.6 Send Engine 事件同步（Member Center → Send Engine）
1) Member Center 發出事件（`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`）
2) 以 webhook 推送至 Send Engine（簽章與重放防護）
3) `X-Client-Id` 使用 Send Engine `auth_clients.id`（可按 tenant 做設定覆蓋）
4) Send Engine 驗證 tenant scope，更新本地名單快照

### 6.7 Send Engine 退信/黑名單回寫（選用）
1) Send Engine 依事件類型決定回寫時機與原因碼：
2) `hard_bounce` / `soft_bounce_threshold` / `suppression`：Member Center 取消該 email 所有訂閱（跨租戶）並加入黑名單
3) `complaint`：Member Center 僅取消該筆訂閱，不加入黑名單
4) 回寫請求需帶 `tenant_id + subscriber_id + list_id`，Member Center 端做租戶邊界驗證
5) 回寫授權可用：
   - tenant client scope：`newsletter:events.write`
   - platform client scope：`newsletter:events.write.global`（SES 聚合事件）

### 6.5 註冊後銜接
1) 使用者完成註冊
2) 系統搜尋 `newsletter_subscriptions.email`
3) 將 `user_id` 補上並保留偏好
4) 可選：發出事件 `subscription.linked_to_user`

### 6.8 已登入修改密碼
1) 使用者登入會員中心
2) 進入 change password 頁面
3) 提交 `current_password + new_password`
4) 系統驗證目前密碼正確後更新 password hash
5) 更新成功後刷新目前 session

### 6.8b 會員基本資料維護
1) 使用者登入會員中心
2) 進入 profile 頁
3) 讀取基本資料（姓、名、nick name、電話、生日、性別、公司、部門、職稱、統編、remark 等）
4) 更新後寫入 `user_profiles`
5) 記錄 audit log

### 6.8c 地址簿管理
1) 使用者登入會員中心
2) 進入地址簿頁面
3) 新增、編輯或刪除地址
4) 系統驗證地址資料歸屬目前 user
5) 若設定預設地址，需確保同用途只有一筆預設值
6) 若只剩最後一筆地址，不允許刪除
7) 記錄 audit log

### 6.8d 已登入會員管理自己的訂閱
1) 使用者登入會員中心
2) 系統依 `user_id` 讀取已綁定的電子報訂閱
3) 顯示可管理的訂閱清單
4) 使用者可直接取消訂閱
5) 系統驗證訂閱歸屬目前 user 後更新狀態
6) 發送 `subscription.unsubscribed` 事件
7) 不需再次經過 email token 驗證
8) 已綁定 `user_id` 後仍保留 `list_id + email` 的 public 訂閱 / 退訂入口

### 6.9 檔案存取授權（File Access）
1) Upload 採 S2S：
   - `A service` 以 `client_credentials` 向 Member Center 取 token
   - token 帶 file upload 專用 scope
   - `A service` 帶 token 呼叫 access agent / file space
   - access agent 以 JWKS 驗簽 JWT，並驗 `iss/aud/exp/scope/tenant_id`
2) Download 採 delegated short-lived token：
   - client 向 `A service` 要求下載
   - `A service` 以自己的商業邏輯驗證 Member Center user 身份與檔案權限
   - `A service` 以 `files:download.delegate` 向 Member Center 申請短效 download token
   - client 帶短效 token 直接向 access agent / file space 請求檔案
   - access agent 以 `files:download.read` 回打 Member Center 驗證 token 與實際 GET request 邊界一致後放行

規則：
- 不直接將一般 S2S access token 暴露給 client 作為下載 token
- download token 應至少帶：
  - `tenant_id`
  - `user_id`
  - `file_id` 或 `object_key`
  - `method=GET`
  - 短效 `exp`
  - 建議帶 `jti`
- upload token 應至少帶：
  - `aud=file_access_api`
  - `scope=files:upload.write`
  - `tenant_id`
- access agent 應驗：
  - Member Center validation endpoint 回傳 `active=true`
  - `tenant_id`
  - 檔案識別與 method 是否與 token 一致

### 6.10 外部資源授權抽象（Audience / Resource Registry）
目的：
- 統一 Send Engine、File Access 與未來其他外部服務的 token 發放規則
- 避免每新增一個服務就新增一組 `Auth__XAudience` 與對應程式分支

共通原則：
- Member Center 為 token issuer 與 JWKS 提供者
- 外部服務皆以 `iss/aud/exp/scope/tenant_id` 驗證 token
- `aud` 不直接以程式硬編碼，而是由 resource registry 決定

resource registry 至少需定義：
- `resource_name`
- `audience`
- `allowed_scopes`
- `allowed_client_usages`
- `requires_tenant`
- `allows_delegated_token`

建議初始資源：
- `member_center_api`
  - scopes：`openid`、`email`、`profile`、`newsletter:list.read`、`newsletter:subscriptions.read`、`newsletter:subscriptions.write`、`newsletter:events.write`、`newsletter:events.write.global`、`profile:basic.read`、`profile:basic.write`、`profile:addresses.read`、`profile:addresses.write`、`profile:subscriptions.read`、`profile:subscriptions.write`
  - usages：`tenant_api`、`platform_service`、互動式登入 client
- `send_engine_api`
  - scopes：`newsletter:send.write`、`newsletter:send.read`
  - usages：`send_api`
- `file_access_api`
  - scopes：`files:upload.write`、`files:download.read`、`files:download.delegate`、`files:delete`、`files:metadata.read`
  - usages：`file_api`

設計原則：
- resource registry 由 DB 與管理 UI 管理非敏感欄位
- `TokenController` 依 scope 與 usage 對照 resource registry 計算 resources / audiences
- delegated download token 由 Member Center 簽發與線上驗證；一般 S2S token 仍由 resource registry 決定 audience
- 對外資料讀寫授權完全由 scope 決定，只要 client 被授權該 scope 即可存取對應能力

目前實作狀態：
- DB registry 第一版已加入 `auth_resources`、`auth_resource_scopes`、`auth_client_usage_permissions`
- 預設 seed 已包含 `member_center_api`、`send_engine_api`、`file_access_api`
- OAuth client usage-scope matrix 已由 DB 驅動，包含 `file_api`
- Authorization Code 與 Client Credentials granted scopes 都由 OAuth application `usage` mapping 固定核發
- Usage mapping seed 採完整集合同步，會停用已從預設 mapping 移除的 scopes
- resource registry 管理 UI 仍待補
- delegated download token issuing / validation 已由 Member Center API 負責，File Access agent 以 validation endpoint 確認 client GET request 帶來的 token 是否有效

## 7. API 介面（草案）
- GET `/oauth/authorize`
- POST `/oauth/token`
- GET `/.well-known/openid-configuration`
- Password grant 與 `/auth/login`、`/auth/refresh` aliases 已停用；token exchange 統一使用 `/oauth/token`
- POST `/newsletter/subscribe`
- GET `/newsletter/confirm`
- POST `/newsletter/unsubscribe`
- POST `/newsletter/unsubscribe-token`
- POST `/newsletter/one-click-unsubscribe-token`
- POST `/newsletter/one-click-unsubscribe-tokens`
- GET `/newsletter/preferences`
- POST `/newsletter/preferences`
- POST `/webhooks/subscriptions`（Send Engine 端點，Member Center 呼叫）
- POST `/webhooks/lists/full-sync`（Send Engine 端點，Member Center 呼叫）
- POST `/subscriptions/disable`（Member Center 端點，Send Engine 呼叫）
- POST `/integrations/send-engine/webhook-clients/upsert`（Member Center 端點，Send Engine 呼叫）

## 7.1 進度狀態（2026-02）
### API
- `GET /newsletter/subscriptions?list_id=...`：已實作
- `POST /webhooks/subscriptions`：已實作（Member Center 發送）
- `POST /subscriptions/disable`：已實作
- `POST /integrations/send-engine/webhook-clients/upsert`：已實作
- `POST /webhooks/lists/full-sync`：尚未實作（保留規格）

### Auth / Scope
- `tenant_api` / `send_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`，所有清單/事件 API 需驗證租戶邊界
- OAuth Client 需區分用途：`tenant_api` / `send_api` / `webhook_outbound` / `platform_service` / `file_api`（禁止混用）
- 新增 scope：`newsletter:list.read`、`newsletter:list.read.global`、`newsletter:subscriptions.read`、`newsletter:subscriptions.write`、`newsletter:send.write`、`newsletter:send.read`、`newsletter:events.read`
- 新增 scope：`newsletter:events.write`
- 新增 scope：`newsletter:events.write.global`
- 規劃新增 profile scopes：
  - `profile:basic.read`
  - `profile:basic.write`
  - `profile:addresses.read`
  - `profile:addresses.write`
  - `profile:subscriptions.read`
  - `profile:subscriptions.write`
- 規劃新增 file access scopes：
  - `files:upload.write`
  - `files:download.read`
  - `files:download.delegate`
  - `files:delete`
  - `files:metadata.read`
- 規劃新增 audience：`file_access_api`
- JWT Access Token 已改為 JWS（`DisableAccessTokenEncryption`），供 Send Engine 以 JWKS 驗簽
- `aud` 計算由 resource registry 驅動，不於 token 發放流程硬寫各服務 audience
- tenant-scoped API 採 fail-closed：token 缺少、格式錯誤或不相符的 `tenant_id` 時拒絕；跨 tenant 操作必須使用獨立 global scope

### 租戶端取 Token（Client Credentials）
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
- token 內含 `tenant_id` 與 scope
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWT（JWS）
- 驗簽通過後將 `tenant_id` 固定在 request context，不接受 body 覆寫
- File Access 與未來外部服務亦沿用此模型；File Access delegated download token 由 Member Center 簽發與線上驗證
- 若其他服務要讀取會員個資，應只授予必要的 profile read scopes，不應沿用過寬的 `profile` OIDC scope
- service API 為主要整合模式，存取控制以 scopes 為唯一授權來源

## 7.2 尚未完成（待辦）
- `POST /webhooks/lists/full-sync`：Member Center 端尚未發送此事件（僅保留契約）
- 註冊後訂閱綁定（`newsletter_subscriptions.user_id` 補值）已在註冊 / external login 流程落地
- `subscription.linked_to_user` 事件已發送
- 安全設定頁的 access/refresh 時效已套用到每次 OpenIddict token 簽發；access 限 `5–1440` 分鐘，refresh 限 `1–365` 天
- Audit Logs 目前以查詢為主，關鍵操作的寫入覆蓋率仍不足
- resource registry 已完成 DB 驅動第一版；file access delegated token issuing / validation 已由 Member Center API 實作

## 8. 安全與合規
- API / Web 的 Production database connection string 採 fail-closed，禁止缺少設定時退回 localhost 預設帳密
- Member Center Web 對所有不安全 HTTP method 啟用全域 antiforgery 驗證；Admin POST 表單明確送出 antiforgery token
- SMTP 密碼以版本化 Data Protection ciphertext 儲存；既有明碼在首次讀取時自動轉換，不回傳至管理 UI
- Production 必須再以外部憑證保護 Data Protection key ring；key ring 與密文同存 DB 只能避免設定值直接裸露，不能抵抗完整 DB 外洩
- Production API 必須以設定注入的 PFX 作為 OpenIddict signing / encryption certificates；API 與 Web 使用同一張外部 PFX 保護共用 Data Protection key ring
- Forwarded headers 採 trusted proxy allowlist；未設定 proxy IP/CIDR 時完全忽略，避免外部來源偽造 client IP 或 HTTPS scheme
- HTTPS redirect 與 HSTS 由 AWS ALB / CloudFront 邊界負責；API / Web 私有 listener 使用 HTTP，不在應用程式層 redirect
- Production issuer 固定為 canonical HTTPS URL；同 VPC S2S 可用 `Auth:AllowInternalHttpTokenEndpoint` 直連私有 HTTP OAuth endpoint，兩者語意分離
- `tests/MemberCenter.Security.Tests` 是標準 xUnit test project，供 CI 以 `dotnet test` 驗證 redirect、proxy、憑證與 security options 邊界
- Member Center Web 全域套用 self-only CSP、anti-framing、nosniff、no-referrer 與 Permissions-Policy；Razor 不保留 inline script/style
- Identity 密碼/lockout、API/Web rate limits、Newsletter token lifetime/batch limit 與 File Access delegated token lifetime 均由強型別 runtime options 管理，預設值記錄於 `docs/CONFIGURATION.md`
- 密碼強度與防暴力破解（rate limit + lockout）
- Token rotation + refresh token revoke
- Redirect URI 白名單 + PKCE
- Double opt-in（可配置）
- Audit log
- delegated download token 需短效、不可重放，必要時可引入 `jti` 與 nonce/jti blacklist
- Email 可作為未來 MFA 的挑戰通道
- GDPR/CCPA：資料匯出與刪除（規劃中）

## 9. 其他文件
- `docs/UI.md`
- `docs/USE_CASES.md`
- `docs/FLOWS.md`
- `docs/OPENAPI.md`
- `docs/SCHEMA.sql`
- `docs/TECH_STACK.md`
