# 系統設計草案(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|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 不同 origin,Web 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 signature(JWKS) - `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 ### 租戶端取 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 與未來外部服務亦沿用此模型,差異由 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`