member_center/docs/DESIGN.md
2026-04-23 00:30:09 +09:00

346 lines
16 KiB
Markdown
Raw 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.

# 系統設計草案Member Center
日期2026-01-30
## 1. 需求摘要(已確認)
1) 多個網站共用的會員登入中心SSO
2) 電子報訂閱管理(發送另建)
3) 未註冊會員可訂閱;註冊後沿用訂閱資料
4) 未註冊會員可取消訂閱(單一清單退訂)
5) 登入需支援 API 與 Redirect 兩種方式OAuth2 + OIDC
## 2. 架構原則
- OAuth2 + OIDCAuthorization Code + PKCE
- 會員中心只管理 Email 與訂閱狀態
- 檔案存取授權沿用 OAuth2/JWT/JWKS並以 scope + claim 做資源邊界控制
- 對外資料 API 以 service API 為主,授權完全由 scope 控制
- Double Opt-in
- 各站自行設計 UI主要走 API少數狀況使用 redirect
- 多租戶為邏輯隔離,但會員資料跨站共享
- 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作
- 訂閱狀態同步採 webhookevent payloadqueue 可作為未來擴充選項
- 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|webhook_outbound|platform_service`
- 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
- 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 不同 originWeb login 僅允許導回 `Auth:Issuer``Auth:AllowedLoginReturnUrlPrefixes` 內的 return URL。
- API 可用 `Auth:WebLoginUrl` 指定登入頁位置;預設為 `/account/login`
- `web_login` 可使用 public client + PKCE不要求 client secret。
- `web_login` client 可使用 `openid email profile`,並預留 `profile:basic.read`
### 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` 驗商業規則與檔案權限
- `A service` 取得或簽發短效 download token
- client 帶短效 token 直接向 access agent / file space 請求檔案
- access agent 驗 token 後放行
規則:
- 不直接將一般 S2S access token 暴露給 client 作為下載 token
- download token 應至少帶:
- `aud=file_access_api`
- `scope=files:download.read`
- `tenant_id`
- `file_id``object_key`
- `method=GET`
- 短效 `exp`
- 建議帶 `jti`
- upload token 應至少帶:
- `aud=file_access_api`
- `scope=files:upload.write`
- `tenant_id`
- access agent 應驗:
- JWT signatureJWKS
- `iss`
- `aud`
- `exp`
- `scope`
- `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: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 token 與一般 S2S token 共用同一套 resource registry不重複維護 audience 規則
- 對外資料讀寫授權完全由 scope 決定,只要 client 被授權該 scope 即可存取對應能力
## 7. API 介面(草案)
- GET `/oauth/authorize`
- POST `/oauth/token`
- GET `/.well-known/openid-configuration`
- POST `/auth/login` (API-only login)
- POST `/auth/refresh`
- 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: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
### 租戶端取 TokenClient Credentials
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
- token 內含 `tenant_id` 與 scope
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWTJWS
- 驗簽通過後將 `tenant_id` 固定在 request context不接受 body 覆寫
- File Access 與未來外部服務亦沿用此模型,差異由 scopes / audience / delegated token 規則表達
- 若其他服務要讀取會員個資,應只授予必要的 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 lifetime
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足
- resource registry 與 file access token issuing 尚未實作,現況仍是兩組 audience 硬編碼
## 8. 安全與合規
- 密碼強度與防暴力破解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`