Compare commits
No commits in common. "develop" and "master" have entirely different histories.
@ -1,9 +1,6 @@
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||
Auth__Issuer=http://localhost:7850/
|
||||
Auth__WebLoginUrl=http://localhost:5080/account/login
|
||||
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
|
||||
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
|
||||
Auth__MemberCenterAudience=member_center_api
|
||||
Auth__SendEngineAudience=send_engine_api
|
||||
SendEngine__BaseUrl=http://localhost:6060
|
||||
|
||||
@ -17,8 +17,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Installer", "s
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Web", "src\MemberCenter.Web\MemberCenter.Web.csproj", "{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.TestSite", "src\MemberCenter.TestSite\MemberCenter.TestSite.csproj", "{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -52,10 +50,6 @@ Global
|
||||
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{7733733D-22EB-431D-A8AA-833486C3E0E2} = {150D3A20-BF61-4012-BD40-05D408749112}
|
||||
@ -64,6 +58,5 @@ Global
|
||||
{051ECE48-E49B-4E42-BE08-6E9AAB7262BC} = {150D3A20-BF61-4012-BD40-05D408749112}
|
||||
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2} = {150D3A20-BF61-4012-BD40-05D408749112}
|
||||
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561} = {150D3A20-BF61-4012-BD40-05D408749112}
|
||||
{ABA4996C-B48F-444C-BEF2-83BDED5D74B2} = {150D3A20-BF61-4012-BD40-05D408749112}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
13
README.md
13
README.md
@ -44,16 +44,13 @@
|
||||
- `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信)
|
||||
- `docs/TECH_STACK.md`:技術棧與選型
|
||||
- `docs/INSTALL.md`:安裝、初始化與維運指令
|
||||
- `docs/MEMBER_UPGRADE_PLAN.md`:會員中心下一階段升級規劃(設定畫面、SMTP、Email 驗證、忘記密碼、角色分級)
|
||||
- `docs/TEST_SITE.md`:手動整合測試站說明(API login、redirect login、會員 API happy path)
|
||||
|
||||
## 專案結構
|
||||
```text
|
||||
member_center/
|
||||
├── src/
|
||||
│ ├── MemberCenter.Api/ # REST API(OAuth/OIDC、訂閱、管理 API)
|
||||
│ ├── MemberCenter.Web/ # MVC Web UI(client-first 會員介面 + Areas/Admin 管理介面)
|
||||
│ ├── MemberCenter.TestSite/ # 手動整合測試站(API / redirect login happy path)
|
||||
│ ├── MemberCenter.Web/ # MVC Web UI(會員與後台頁面)
|
||||
│ ├── MemberCenter.Installer/ # 安裝與初始化 CLI(migrate/init/admin)
|
||||
│ ├── MemberCenter.Application/ # 應用層介面與 DTO
|
||||
│ ├── MemberCenter.Infrastructure/# EF Core、Identity、OpenIddict、服務實作
|
||||
@ -69,11 +66,3 @@ member_center/
|
||||
- 事件系統選擇(Kafka/RabbitMQ/SNS+SQS)
|
||||
- 取消訂閱的 UI 形式(純一鍵或提供偏好)
|
||||
- GDPR/CCPA 資料匯出與刪除是否納入第一期
|
||||
|
||||
## 目前 UI / Auth 狀態
|
||||
- `MemberCenter.Web` 採單一登入入口,client 與 admin 共用同一套帳號。
|
||||
- `admin` role 使用者在 client 主介面中可看到 `Admin` 功能群組。
|
||||
- `/admin/*` 已移至 `Areas/Admin`,未登入或非 admin 存取時一律回 `404`。
|
||||
- 本地註冊維持 `UserName = Email`,新帳號預設為未認證但可登入。
|
||||
- 已支援 Google external login / register 與同 email auto-link。
|
||||
- 註冊或 external login 建立帳號後,若已存在同 email 訂閱資料,會自動補 `newsletter_subscriptions.user_id`、寫入 audit log,並發送 `subscription.linked_to_user` 事件。
|
||||
|
||||
@ -1,260 +0,0 @@
|
||||
# Admin / Client UI 拆分工作計劃
|
||||
|
||||
## 目標
|
||||
|
||||
- 在同一個 `MemberCenter.Web` 專案中,將會員端與管理端 UI 明確分區。
|
||||
- 保持單一登入入口與單一帳號系統。
|
||||
- 讓具有 `admin` 權限的帳號同時可使用會員功能與管理功能。
|
||||
- 將目前偏向後台的共用介面,調整為 client-first 的會員中心體驗。
|
||||
- 補齊會員註冊、Google 第三方登入與訂閱資料銜接流程。
|
||||
|
||||
## 已確認決策
|
||||
|
||||
### 帳號與登入
|
||||
|
||||
- `Admin` 與 `Client` 共用同一個登入入口。
|
||||
- `Admin` 帳號同時也是會員帳號,可使用會員介面功能。
|
||||
- 不拆成兩套認證系統,不建立獨立 admin login。
|
||||
|
||||
### UI 與導覽
|
||||
|
||||
- 會員端為主介面。
|
||||
- 功能選單採分組方式呈現。
|
||||
- 一般會員只看到會員功能。
|
||||
- 具有 `admin` role 的帳號,額外看到 `Admin` 功能分類。
|
||||
- `Admin` 分類展開後顯示管理功能連結。
|
||||
- 目前先以 `admin` role 做整包顯示,不先做細權限切分。
|
||||
|
||||
### 未授權存取
|
||||
|
||||
- 非 admin 使用者存取 `/admin/*` 時,回應 `404`。
|
||||
- 不使用 `403` 頁面暴露後台存在。
|
||||
|
||||
## 目標範圍
|
||||
|
||||
### 本次要做
|
||||
|
||||
- 調整 `MemberCenter.Web` 路由與結構,將管理端移入 `Areas/Admin`。
|
||||
- 將現有共用 layout 改為 client-first 導覽。
|
||||
- 將 admin 功能從全站共用導覽中抽離,改成 role-based 顯示。
|
||||
- 建立 admin 路由未授權時回 `404` 的處理方式。
|
||||
- 補上會員註冊、第三方登入與訂閱綁定的工作規劃。
|
||||
|
||||
### 本次不做
|
||||
|
||||
- 細粒度權限模型,例如依功能模組拆 `tenant.read`、`audit.read`。
|
||||
- 獨立的 `AdminWeb` / `ClientWeb` 專案拆分。
|
||||
- 大幅重做視覺設計。
|
||||
- API 權限模型重構。
|
||||
- 註冊確認信寄送實作。
|
||||
|
||||
## 實作策略
|
||||
|
||||
### 策略原則
|
||||
|
||||
- 先切 UI 邊界,再保留既有 Identity 與 role policy。
|
||||
- 先做低風險結構重整,不同時引入細權限與大幅 UI redesign。
|
||||
- 保持既有 URL 慣例,避免不必要的 route breakage。
|
||||
|
||||
### 預期結構
|
||||
|
||||
```text
|
||||
src/MemberCenter.Web/
|
||||
├── Areas/
|
||||
│ └── Admin/
|
||||
│ ├── Controllers/
|
||||
│ └── Views/
|
||||
├── Controllers/ # client only
|
||||
├── Views/
|
||||
│ ├── Shared/
|
||||
│ │ ├── _Layout.cshtml # client-first layout
|
||||
│ │ └── ...
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
## 分階段計劃
|
||||
|
||||
### Phase 0: 會員註冊與帳號銜接規格補齊
|
||||
|
||||
狀態:已完成
|
||||
|
||||
目標:先將會員建立、第三方登入與訂閱資料綁定的規則固定,避免後續 UI 與 auth 重構互相衝突。
|
||||
|
||||
#### 需求規則
|
||||
|
||||
- 會員帳號以 `email` 為主要識別。
|
||||
- `UserName` 強制等於 `Email`。
|
||||
- 不提供獨立 username。
|
||||
- 本地註冊完成後,帳號標記為未認證。
|
||||
- 本階段先不寄送確認信。
|
||||
- 未認證帳號仍可登入。
|
||||
- 後續功能完整後,未認證帳號將可被限制部分功能;本階段先保留此狀態與擴充空間。
|
||||
- 支援 Google 作為第一個第三方登入/註冊 provider。
|
||||
- Google 第一次登入時,若系統已存在相同 email 的本地帳號,直接 auto-link。
|
||||
- Google 回傳 email 即使未驗證,仍允許建立帳號或連接既有帳號。
|
||||
- 使用者若先以本地帳號註冊,之後再以 Google 同 email 登入,應連接到同一個帳號,不建立第二個 user。
|
||||
- 註冊成功後,若系統中已有相同 email 的訂閱資料,需將相關 `newsletter_subscriptions.user_id` 補上。
|
||||
- 訂閱綁定時必須保留既有訂閱狀態與偏好,不可覆蓋。
|
||||
- 訂閱綁定完成後需補一筆 audit log。
|
||||
- 後續若導入事件,保留發送 `subscription.linked_to_user` 的擴充空間。
|
||||
|
||||
#### 子工作
|
||||
|
||||
- 已完成:定義本地註冊後的帳號狀態與登入規則。
|
||||
- 已完成:定義 Google external login / register / auto-link 流程。
|
||||
- 已完成:定義訂閱資料綁定與 audit log 寫入時機。
|
||||
- 已完成:將上述規則同步反映到目前的 Web / API 實作階段。
|
||||
- 已完成:`subscription.linked_to_user` 事件發送。
|
||||
- 註記:未認證帳號的功能限制屬後續能力擴充,不阻擋本 phase 完成。
|
||||
- 註記:Google 實際整合驗證仍需提供 Google OAuth 設定,屬外部驗證條件,不阻擋本 phase 完成。
|
||||
|
||||
完成條件:
|
||||
|
||||
- 註冊、Google 登入、同 email 帳號連接、訂閱綁定規則均有明確定義。
|
||||
- 後續 Phase 1 之後的 UI 與 auth 重構可直接依規則實作。
|
||||
|
||||
### Phase 1: Route 與目錄切分
|
||||
|
||||
狀態:已完成
|
||||
|
||||
目標:先建立清楚的 UI 邊界。
|
||||
|
||||
- 將現有 `Controllers/Admin/*` 移入 `Areas/Admin/Controllers/*`。
|
||||
- 將現有 `Views/Admin/*` 移入 `Areas/Admin/Views/*`。
|
||||
- 調整 route 設定,讓 `/admin/*` 由 area route 處理。
|
||||
- 確認既有 admin URL 可維持不變。
|
||||
|
||||
完成條件:
|
||||
|
||||
- 所有 admin 頁面由 `Areas/Admin` 提供。
|
||||
- 會員端 controller 不再與 admin controller 混在同一層。
|
||||
|
||||
### Phase 2: Layout 與導覽切分
|
||||
|
||||
狀態:已完成
|
||||
|
||||
目標:把 UI 改成 client-first,不再全站露出後台功能。
|
||||
|
||||
- 重構共用 layout,移除固定顯示的 admin 連結。
|
||||
- 建立 client-first 功能選單。
|
||||
- 若使用者具 `admin` role,顯示 `Admin` 功能分類。
|
||||
- `Admin` 分類底下先列出既有管理功能:
|
||||
- Tenants
|
||||
- Newsletter Lists
|
||||
- Subscriptions
|
||||
- OAuth Clients
|
||||
- Audit Logs
|
||||
- Security
|
||||
- Blacklist
|
||||
|
||||
完成條件:
|
||||
|
||||
- 一般會員不會在主選單看到 admin 連結。
|
||||
- admin 使用者可從同一套主介面展開進入管理功能。
|
||||
|
||||
### Phase 3: Admin 畫面容器整理
|
||||
|
||||
狀態:已完成
|
||||
|
||||
目標:讓進入 admin 區後有明確上下文。
|
||||
|
||||
- 規劃 admin area 是否使用獨立 layout。
|
||||
- 若使用獨立 layout,保留回會員區入口。
|
||||
- 若先共用 layout,至少在 admin 頁面標示目前位於管理區。
|
||||
|
||||
建議:
|
||||
|
||||
- 第一版可先採用共用主殼 + admin 區塊標示。
|
||||
- 若後續 admin 功能持續增長,再抽 `_AdminLayout`。
|
||||
|
||||
目前進度:
|
||||
|
||||
- 已完成:獨立 `Admin` area layout。
|
||||
- 已完成:保留回會員區入口。
|
||||
- 已完成:admin shell 基礎結構整理(top bar、side nav、active state、區域標示)。
|
||||
- 已完成:補上可替換的基礎樣式 hooks,避免後續設計重做時需要拆 route 或 view 結構。
|
||||
- 註記:後續若需進一步整理 admin/client 的整體體驗、內容層級、表格與表單版型,視為 UI/UX refinement,不阻擋本 phase 完成。
|
||||
|
||||
完成條件:
|
||||
|
||||
- 使用者進入 admin 頁面時,有清楚的區域辨識。
|
||||
|
||||
### Phase 4: 未授權存取改為 404
|
||||
|
||||
狀態:已完成
|
||||
|
||||
目標:保留授權檢查,同時隱藏 admin surface。
|
||||
|
||||
- 保留 `[Authorize(Policy = "Admin")]`。
|
||||
- 增加 admin 未授權時的統一處理,避免顯示預設 `403`。
|
||||
- 確認未登入與已登入但非 admin 的行為符合預期。
|
||||
|
||||
已定案:
|
||||
|
||||
- 未登入進 `/admin/*`:直接回 `404`
|
||||
- 已登入但非 admin 進 `/admin/*`:直接回 `404`
|
||||
|
||||
目前進度:
|
||||
|
||||
- 已完成:保留 `[Authorize(Policy = "Admin")]`。
|
||||
- 已完成:admin 未授權時不顯示預設 `403`,改為 `404`。
|
||||
- 已完成:目前實作上,未登入與非 admin 存取 `/admin/*` 均回 `404`。
|
||||
- 已完成:將「未登入也回 `404`」正式定案並同步到工作計劃。
|
||||
|
||||
### Phase 5: 驗證與文件更新
|
||||
|
||||
狀態:已完成
|
||||
|
||||
目標:確保重構後行為可驗證、文件一致。
|
||||
|
||||
- 驗證會員端主要頁面仍可正常使用。
|
||||
- 驗證 admin 帳號可以:
|
||||
- 使用會員功能
|
||||
- 看見 `Admin` 選單
|
||||
- 進入 admin 各頁
|
||||
- 驗證非 admin 帳號無法看見 admin 選單,且直接進 admin URL 會得到 `404`
|
||||
- 更新 README / UI 文件中的 web 結構描述
|
||||
|
||||
目前進度:
|
||||
|
||||
- 已完成:`dotnet build MemberCenter.sln -m:1`
|
||||
- 已完成:會員端主要頁面可用性驗證(首頁 / login / register)。
|
||||
- 已完成:admin 帳號操作驗證(可登入、可進 member profile、可進 admin route)。
|
||||
- 已完成:一般會員登入驗證(可登入、可進 profile、首頁不顯示 admin 群組)。
|
||||
- 已完成:非 admin / 未登入情境驗證(匿名打 `/admin/*` 回 `404`)。
|
||||
- 已完成:計劃文件、README、UI / Flow / Use Case / Design / OpenAPI 相關文件更新。
|
||||
- 註記:Google 真實 round-trip 驗證需提供 Google OAuth 設定,屬外部條件,不阻擋本 phase 完成。
|
||||
|
||||
## 影響檔案預估
|
||||
|
||||
- `src/MemberCenter.Web/Program.cs`
|
||||
- `src/MemberCenter.Web/Views/Shared/_Layout.cshtml`
|
||||
- `src/MemberCenter.Web/Controllers/Admin/*`
|
||||
- `src/MemberCenter.Web/Views/Admin/*`
|
||||
- 新增 `src/MemberCenter.Web/Areas/Admin/...`
|
||||
- 視需要更新 `docs/UI.md`
|
||||
|
||||
## 風險與注意事項
|
||||
|
||||
- Area 導入後,view 路徑與 route mapping 容易有小錯誤,需要逐頁驗證。
|
||||
- 若直接共用同一個 layout,需避免 client 與 admin 的語意混亂。
|
||||
- `404` 偽裝策略要搭配真正的 authorization,不能只靠 route 隱藏。
|
||||
- 若未登入也直接回 `404`,可能會讓合法 admin 使用者失去登入引導;這點需明確決策。
|
||||
|
||||
## 建議執行順序
|
||||
|
||||
1. 先完成 Phase 0,確認註冊、Google 登入與訂閱綁定規則。
|
||||
2. 再完成 Phase 1,做純結構重整。
|
||||
3. 接著做 Phase 2,修正選單與角色顯示。
|
||||
4. 然後決定 Phase 3 要共用 layout 還是抽 admin layout。
|
||||
5. 再做 Phase 4,補齊 `404` 授權行為。
|
||||
6. 最後做 Phase 5 的驗證與文件更新。
|
||||
|
||||
## 本次文件用途
|
||||
|
||||
這份計劃作為後續逐步實作的工作底稿。後續每一步都應以「單一階段可驗證完成」為原則,避免一次改太多導致 routing、授權與 UI 問題混在一起。
|
||||
|
||||
## 目前總結
|
||||
|
||||
- 已完成:Phase 0、Phase 1、Phase 2、Phase 3、Phase 4、Phase 5
|
||||
- 部分完成:Phase 5
|
||||
182
docs/DESIGN.md
182
docs/DESIGN.md
@ -12,13 +12,11 @@
|
||||
## 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 可作為未來擴充選項
|
||||
- 訂閱狀態同步目前採 webhook(event payload);queue 為後續可選擴充
|
||||
- PostgreSQL
|
||||
- 實作:C# .NET Core + MVC + OpenIddict
|
||||
|
||||
@ -30,10 +28,8 @@
|
||||
- 站點後台:可管理站點資訊、訂閱清單、會員基本資料
|
||||
|
||||
## 4. 核心模組
|
||||
- Identity Service:註冊、登入、修改密碼、密碼重設、Email 驗證
|
||||
- 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:驗證信/退訂信/確認信的發送介面(外部系統)
|
||||
@ -43,26 +39,11 @@
|
||||
- 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` 作為國家特有欄位補充,不取代主結構欄位
|
||||
- id, user_name, email, password_hash, email_confirmed, lockout, is_blacklisted, blacklisted_at, blacklisted_by, created_at
|
||||
- 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`)
|
||||
- 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
|
||||
@ -79,8 +60,6 @@
|
||||
- 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
|
||||
@ -91,26 +70,14 @@
|
||||
- 使用者註冊時,如 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。
|
||||
- API 可用 `Auth:WebLoginUrl` 指定登入頁位置;預設為 `/account/login`。
|
||||
- `web_login` 可使用 public client + PKCE,不要求 client secret。
|
||||
- `web_login` client 可使用 `openid email profile`,並預留 `profile:basic.read`。
|
||||
1) 站點導向 `/oauth/authorize`,帶 `client_id`, `redirect_uri`, `code_challenge`, `scope=openid email`
|
||||
2) 使用者於會員中心登入
|
||||
3) 成功後導回 `redirect_uri` 並附 `code`
|
||||
4) 站點以 `code` + `code_verifier` 向 `/oauth/token` 換取 token + `id_token`
|
||||
|
||||
### 6.2 OAuth2 API 使用(站點自行 UI)
|
||||
1) 站點以 API 驗證使用者登入(會員中心提供 login API)
|
||||
@ -157,112 +124,6 @@
|
||||
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: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`
|
||||
- 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`
|
||||
@ -292,43 +153,24 @@ resource registry 至少需定義:
|
||||
|
||||
### 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`(禁止混用)
|
||||
- OAuth Client 需區分用途:`tenant_api` / `send_api` / `webhook_outbound` / `platform_service`(禁止混用)
|
||||
- 新增 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 與未來外部服務亦沿用此模型;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` 事件已發送
|
||||
- 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)尚未在註冊流程落地
|
||||
- `subscription.linked_to_user` 事件尚未發送
|
||||
- 安全設定頁(access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime
|
||||
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足
|
||||
- resource registry 已完成 DB 驅動第一版;file access delegated token issuing / validation 已由 Member Center API 實作
|
||||
|
||||
## 8. 安全與合規
|
||||
- 密碼強度與防暴力破解(rate limit + lockout)
|
||||
@ -336,8 +178,6 @@ resource registry 至少需定義:
|
||||
- Redirect URI 白名單 + PKCE
|
||||
- Double opt-in(可配置)
|
||||
- Audit log
|
||||
- delegated download token 需短效、不可重放,必要時可引入 `jti` 與 nonce/jti blacklist
|
||||
- Email 可作為未來 MFA 的挑戰通道
|
||||
- GDPR/CCPA:資料匯出與刪除(規劃中)
|
||||
|
||||
## 9. 其他文件
|
||||
|
||||
@ -11,19 +11,8 @@
|
||||
## F-02 登入(OAuth2 + OIDC)
|
||||
- [API] 站點送出 `POST /auth/login` 取得 access_token + id_token
|
||||
- [API] 站點建立自身 session
|
||||
- [UI] 使用 `usage=web_login` OAuth client,導向 `/oauth/authorize` 完成 Authorization Code + PKCE
|
||||
- [UI] 若未登入,會員中心會導向 Web login,登入後回到原 authorize request
|
||||
- [UI] 站點用 code + code_verifier 換 token
|
||||
- [UI] `web_login` 可使用 public client,不要求 client secret;必須設定 redirect URI
|
||||
- [UI] 若 Web 與 API 不同 origin,需設定 `Auth:WebLoginUrl`,且 Web 端需允許導回 `Auth:Issuer` 或 `Auth:AllowedLoginReturnUrlPrefixes`
|
||||
- [UI] 若 Web 與 API 位於不同子網域,需設定 `Auth:CookieDomain`,讓 authorize endpoint 可讀取 Web login cookie
|
||||
|
||||
## F-02a 登出(外站 direct logout)
|
||||
- [UI] 外站將瀏覽器導向 Member Center Web `/account/logout`
|
||||
- [UI] Member Center 清除 Web login cookie / session
|
||||
- [UI] 若帶 `returnUrl`,Member Center 僅允許導回本站 local URL 或 allowlist 內的外站 URL
|
||||
- [UI] 登出完成後導回外站 callback
|
||||
- [UI] 外站 callback 清除本站 session / token
|
||||
- [UI] 導向 `/oauth/authorize` 完成授權碼流程
|
||||
- [UI] 站點用 code 換 token + id_token
|
||||
|
||||
## F-02b 內容站台呼叫 Send Engine(Client Credentials + JWT 驗簽)
|
||||
- [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token(`usage=send_api`)
|
||||
@ -31,37 +20,11 @@
|
||||
- [API] Send Engine 以 Member Center JWKS 驗簽 token
|
||||
- [API] 驗證 `scope/tenant_id/exp` 通過後才受理任務
|
||||
|
||||
## F-02c 檔案上傳(A service -> File Space)
|
||||
- [API] `A service` 以 `client_credentials` 向 Member Center 取得 access token
|
||||
- [API] token 需包含 `files:upload.write`
|
||||
- [API] token 應包含 `tenant_id`,並以 `aud=file_access_api` 為目標資源
|
||||
- [API] `A service` 帶 token 呼叫 access agent / file space 上傳檔案
|
||||
- [API] access agent 以 JWKS 驗簽 JWT,並驗 `iss/aud/exp/scope/tenant_id`
|
||||
|
||||
## F-02d 檔案下載(A service -> client -> File Space)
|
||||
- [API] client 向 `A service` 請求下載檔案
|
||||
- [API] `A service` 以自己的商業邏輯驗證該 user 是否可讀取指定檔案
|
||||
- [API] `A service` 以 `files:download.delegate` 呼叫 Member Center `POST /file-access/download-tokens` 取得短效 download token
|
||||
- [API] download token 由 Member Center 簽發,至少綁定 `tenant_id + user_id + file_id/object_key + method=GET + exp`
|
||||
- [UI/API] `A service` 將帶短效 token 的下載 URL 回給 client
|
||||
- [UI/API] client 直接向 access agent / file space 請求檔案
|
||||
- [API] access agent 以 `files:download.read` 呼叫 Member Center `POST /file-access/download-tokens/validate`,確認 token 與實際 GET request 邊界一致後放行
|
||||
|
||||
註記:下載流程不直接暴露一般 S2S token 給 client。
|
||||
|
||||
## F-03 忘記密碼 / 重設密碼
|
||||
- [API] 站點送出 `POST /auth/password/forgot`
|
||||
- [UI] 會員中心頁提交 email 並發送重設信
|
||||
- [API/UI] 使用 token 進入重設密碼頁
|
||||
|
||||
註記:目前 Web UI 已實作 forgot/reset 流程,但尚未串接 email 發送;開發階段會直接顯示 reset token 與 reset 連結。
|
||||
|
||||
## F-03b 已登入修改密碼
|
||||
- [UI] 使用者登入後進入 `/account/changepassword`
|
||||
- [UI] 輸入目前密碼與新密碼
|
||||
- [UI] 會員中心驗證目前密碼後更新密碼
|
||||
- [UI] 更新成功後刷新登入狀態
|
||||
|
||||
## F-04 訂閱電子報(未登入)
|
||||
- [API] 站點送出 `POST /newsletter/subscribe`
|
||||
- [API] 會員中心建立 pending 訂閱並發送驗證信
|
||||
@ -85,15 +48,6 @@
|
||||
- [API] 站點以 `list_id + email` 更新 `/newsletter/preferences`
|
||||
- [UI] 會員中心提供偏好頁(可選)
|
||||
|
||||
## F-06b 我的電子報訂閱管理(登入後)
|
||||
- [UI] 使用者登入後進入 `/profile/subscriptions`
|
||||
- [API/UI] 會員中心依目前登入 user 查出其已綁定的訂閱清單
|
||||
- [UI] 顯示各租戶 / 清單 / 狀態 / 訂閱時間
|
||||
- [UI] 使用者可直接按下取消訂閱
|
||||
- [API/UI] 系統驗證該訂閱屬於目前登入 user 後直接退訂
|
||||
- [API/UI] 不需再次 email token 驗證
|
||||
- [API] 發出 event `subscription.unsubscribed`
|
||||
|
||||
## F-10 Send Engine 事件同步(Member Center → Send Engine)
|
||||
- [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`(scope: `newsletter:events.write`)
|
||||
- [API] Header 使用 `X-Signature` / `X-Timestamp` / `X-Nonce` / `X-Client-Id`
|
||||
@ -121,27 +75,11 @@
|
||||
- [API] 站點讀取 `/user/profile`
|
||||
- [UI] 會員中心提供個人資料頁
|
||||
|
||||
## F-07b 會員資料維護
|
||||
- [UI] 使用者登入後進入 `/profile`
|
||||
- [UI] 維護電話、公司、統編等基本資料
|
||||
- [API/UI] 儲存會員基本資料
|
||||
|
||||
## F-07c 地址簿管理
|
||||
- [UI] 使用者登入後進入 `/profile/addresses`
|
||||
- [UI] 新增、編輯、刪除收貨地址
|
||||
- [UI] 可設定預設地址
|
||||
- [API/UI] 地址簿資料綁定目前登入 user
|
||||
- [API/UI] 若只剩最後一筆地址,不允許刪除
|
||||
|
||||
## F-07d 其他服務讀取會員資料
|
||||
- [API] 其他服務以 access token 呼叫會員中心 profile API
|
||||
- [API] token 需帶對應 profile scopes
|
||||
- [API] 會員中心依 scope 決定可讀取基本資料、地址簿或訂閱資料
|
||||
- [API] service API 為主要模式,只要 client 具對應 scope 即可存取對應資料
|
||||
|
||||
## F-08 管理者管理租戶/清單/Client
|
||||
- [UI] 會員中心管理後台進行 CRUD
|
||||
|
||||
## F-09 訂閱與會員綁定
|
||||
- [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定
|
||||
- [API] 發送事件 `subscription.linked_to_user`
|
||||
|
||||
註記:此流程目前尚未在程式中落地(屬待辦)。
|
||||
|
||||
@ -41,33 +41,12 @@
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||
Auth__Issuer=http://localhost:7850/
|
||||
Auth__WebLoginUrl=http://localhost:5080/account/login
|
||||
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
|
||||
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
|
||||
Auth__Resources__MemberCenter__Audience=member_center_api
|
||||
Auth__Resources__SendEngine__Audience=send_engine_api
|
||||
Auth__Resources__FileAccess__Audience=file_access_api
|
||||
Auth__MemberCenterAudience=member_center_api
|
||||
Auth__SendEngineAudience=send_engine_api
|
||||
SendEngine__BaseUrl=http://localhost:6060
|
||||
SendEngine__WebhookSecret=change-me
|
||||
```
|
||||
|
||||
相容性說明:
|
||||
- 現行程式已優先使用 resource registry 與目標型態:
|
||||
- `Auth__Resources__MemberCenter__Audience`
|
||||
- `Auth__Resources__SendEngine__Audience`
|
||||
- `Auth__Resources__FileAccess__Audience`
|
||||
- 舊 key 仍保留相容讀取:
|
||||
- `Auth__MemberCenterAudience`
|
||||
- `Auth__SendEngineAudience`
|
||||
- 規劃上將收斂為 DB resource registry;`.env` 僅作為初始 seed / 部署覆寫來源,不應再為每個新服務新增平行 hardcoded key。
|
||||
- `File Access` 已直接採用 resource registry 形式,不新增第三組硬編碼 audience 判斷。
|
||||
|
||||
OIDC / Redirect login 設定說明:
|
||||
- `Auth__WebLoginUrl`: API `/oauth/authorize` 未登入時導向的 Web login URL。
|
||||
- `Auth__AllowedLoginReturnUrlPrefixes`: Web login 成功後允許 redirect 回去的 URL prefix,通常填 API issuer/base URL。
|
||||
- `Auth__AllowedLogoutReturnUrlPrefixes`: Web logout 後允許 redirect 的 URL prefix。
|
||||
- Identity cookie 固定使用 `SameSite=None`、`Secure=Always`、`Path=/`,因此 stage/prod 必須使用 HTTPS。
|
||||
|
||||
`SendEngine` 設定說明:
|
||||
- `SendEngine__BaseUrl`: Send Engine API base URL
|
||||
- `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致
|
||||
@ -95,12 +74,12 @@ OIDC / Redirect login 設定說明:
|
||||
- 若 appsettings 中缺少連線字串,會互動式詢問並寫入
|
||||
- 若設定環境變數,會優先使用環境變數(不寫入 appsettings)
|
||||
2) 執行 migrations(不 Drop)
|
||||
3) 建立 roles(superuser, admin, support)
|
||||
4) 建立使用者(不存在才建立)並加入 `superuser` 角色
|
||||
3) 建立 roles(admin, support)
|
||||
4) 建立 admin(不存在才建立)並加入 admin 角色
|
||||
5) 寫入安裝鎖定(DB flag: `system_flags` / `installed=true`)
|
||||
|
||||
### 2) `installer add-superuser`
|
||||
用途:新增或提升 superuser
|
||||
### 2) `installer add-admin`
|
||||
用途:新增 superuser
|
||||
|
||||
參數:
|
||||
- `--admin-email <email>`
|
||||
@ -109,13 +88,10 @@ OIDC / Redirect login 設定說明:
|
||||
|
||||
流程:
|
||||
1) 解析連線字串
|
||||
2) 建立使用者並指派 `superuser` 角色
|
||||
2) 建立使用者並指派 admin 角色
|
||||
|
||||
相容性:
|
||||
- 舊指令 `installer add-admin` 仍保留為 alias,目前語意等同 `installer add-superuser`
|
||||
|
||||
### 3) `installer reset-superuser-password`
|
||||
用途:重設指定 superuser 密碼
|
||||
### 3) `installer reset-admin-password`
|
||||
用途:重設指定 admin 密碼
|
||||
|
||||
參數:
|
||||
- `--admin-email <email>`
|
||||
@ -125,9 +101,6 @@ OIDC / Redirect login 設定說明:
|
||||
1) 解析連線字串
|
||||
2) 更新密碼(強制)
|
||||
|
||||
相容性:
|
||||
- 舊指令 `installer reset-admin-password` 仍保留為 alias,目前語意等同 `installer reset-superuser-password`
|
||||
|
||||
### 4) `installer migrate`
|
||||
用途:只執行 migrations
|
||||
|
||||
|
||||
@ -1,791 +0,0 @@
|
||||
# 會員中心升級規劃
|
||||
|
||||
此文件整理會員中心的目標升級架構,包含設定畫面、Email 驗證與忘記密碼、帳號分級與角色管理、會員主資料、訂閱管理,以及作為外部服務 Token / Auth 中心的整體模型。此文件以一次到位的最終設計為準,不再以過渡方案或分階段落地作為主軸。
|
||||
|
||||
## 目標
|
||||
- 增加管理者可操作的系統設定畫面,涵蓋 SMTP、Send Engine 與 Auth 資源設定。
|
||||
- 補齊 Email 驗證與忘記密碼的完整寄信流程。
|
||||
- 建立帳號分級規則,明確區分 `superuser`、`admin` 與一般會員。
|
||||
- 確立會員認證狀態模型,以「已認證 / 未認證」為會員狀態基礎。
|
||||
- 第三方登入以 Google 為唯一支援 provider。
|
||||
- 將「會員中心作為 Token / Auth 中心」的外部服務授權模型文件化,納入 Send Engine 與 File Access。
|
||||
- 擴充會員個人資料、地址簿與會員端訂閱管理能力,並同步定義可供其他服務使用的 profile scopes。
|
||||
- 補齊帳號生命週期、審計紀錄、rate limit 與 MFA 的基礎治理規則。
|
||||
|
||||
## 實作進度(2026-04-17)
|
||||
|
||||
已完成:
|
||||
- 建立 `user_profiles` / `user_addresses` 實體、DbContext 映射與 EF migration
|
||||
- `users` 新增治理欄位:
|
||||
- `last_login_at`
|
||||
- `last_seen_at`
|
||||
- `disabled_at`
|
||||
- `disabled_by`
|
||||
- 註冊與 external login 建立新帳號時,會同步建立空白 profile row
|
||||
- 新增 current-user profile API:
|
||||
- `GET /user/profile`
|
||||
- `POST /user/profile`
|
||||
- `GET /user/addresses`
|
||||
- `POST /user/addresses`
|
||||
- `DELETE /user/addresses/{id}`
|
||||
- `GET /user/subscriptions`
|
||||
- `POST /user/subscriptions/{id}/unsubscribe`
|
||||
- 新增會員端 Web UI:
|
||||
- `/profile`
|
||||
- `/profile/addresses`
|
||||
- `/profile/subscriptions`
|
||||
- 新增會員端直接退訂流程,不需再透過 email token
|
||||
- `profile:*` scopes 已註冊進 OpenIddict:
|
||||
- `profile:basic.read`
|
||||
- `profile:basic.write`
|
||||
- `profile:addresses.read`
|
||||
- `profile:addresses.write`
|
||||
- `profile:subscriptions.read`
|
||||
- `profile:subscriptions.write`
|
||||
- API 已接上 profile scope policies,並補 service API 的 by-email 端點:
|
||||
- `GET /user/profile/by-email`
|
||||
- `POST /user/profile/by-email`
|
||||
- `GET /user/addresses/by-email`
|
||||
- `POST /user/addresses/by-email`
|
||||
- `DELETE /user/addresses/by-email/{id}`
|
||||
- `GET /user/subscriptions/by-email`
|
||||
- `POST /user/subscriptions/by-email/{id}/unsubscribe`
|
||||
- token resource 映射已將 `profile:*` 納入 member center audience
|
||||
- `/admin/security` 已擴充 SMTP 設定欄位:
|
||||
- relay host / port
|
||||
- TLS / SSL
|
||||
- timeout
|
||||
- username / password
|
||||
- sender name / sender email
|
||||
- `/admin/security` 已新增 SMTP 測試信功能,可輸入測試收件 Email 送出測試信
|
||||
- SMTP 設定已收斂到 DB flags,密碼欄位留白時保留既有值
|
||||
- 已建立共用帳號寄信服務,用於 Email 驗證信與密碼重設信
|
||||
- 已抽出共用帳號 Email 模板服務:
|
||||
- 驗證信模板
|
||||
- 密碼重設信模板
|
||||
- 註冊流程已改為寄送 Email 驗證信
|
||||
- forgot password 已改為寄送重設信,不再直接回傳 reset token
|
||||
- 已補 resend verification:
|
||||
- Web:`POST /account/resendverification`
|
||||
- API:`POST /auth/email/resend`
|
||||
- `/admin/security` 已補 `PublicBaseUrl`,作為驗證信與重設信連結的基準 URL
|
||||
- 已補 audit log:
|
||||
- `account.verification_email_sent`
|
||||
- `account.password_reset_email_sent`
|
||||
- `account.email_verified`
|
||||
- `account.password_reset_completed`
|
||||
- 已啟用 Identity lockout 基礎策略:
|
||||
- `MaxFailedAccessAttempts = 5`
|
||||
- `DefaultLockoutTimeSpan = 15 分鐘`
|
||||
- `AllowedForNewUsers = true`
|
||||
- 已落地公開入口 rate limit:
|
||||
- Web:login / register / forgot password / resend verification
|
||||
- API:register / forgot password / resend verification
|
||||
- Newsletter API:public subscribe / unsubscribe token
|
||||
- password grant login 已改為走 `SignInManager.CheckPasswordSignInAsync(..., lockoutOnFailure: true)`,與 Web login 共用 lockout 行為
|
||||
- 已完成 `superuser` / `admin` 權限模型第一版落地:
|
||||
- `Admin` policy 已擴為接受 `admin` 與 `superuser`
|
||||
- 新增 `Superuser` policy
|
||||
- installer `init` / `add-superuser` 會建立或提升 `superuser`
|
||||
- 舊指令別名 `add-admin` / `reset-admin-password` 仍可用
|
||||
- 已新增管理後台帳號治理頁:
|
||||
- `/admin/accounts`
|
||||
- 支援查詢帳號、查看 email verified / role / disabled / last login
|
||||
- 只有 `superuser` 可授予或移除 `admin`
|
||||
- 只有 `superuser` 可停用或啟用帳號
|
||||
- 已補帳號治理規則:
|
||||
- `superuser` 帳號不可在管理 UI 中被降權、停用、刪除或強制變更密碼
|
||||
- disabled 中的 `admin` 可保留 `admin` role
|
||||
- `superuser` 可在管理 UI 強制重設非 `superuser` 帳號密碼
|
||||
- 強制重設密碼後會更新 security stamp 並撤銷既有 OpenIddict authorization / token
|
||||
- 已補帳號治理與生命週期 audit log:
|
||||
- `account.registered`
|
||||
- `account.external_login_linked`
|
||||
- `account.password_changed`
|
||||
- `account.role_changed`
|
||||
- `account.disabled`
|
||||
- `account.enabled`
|
||||
- `system.security_settings_updated`
|
||||
- `system.security_test_email_sent`
|
||||
- 已補登入治理:
|
||||
- Web login / external login / password grant login 成功後更新 `last_login_at` / `last_seen_at`
|
||||
- disabled 帳號無法透過 Web login、external login、password grant 取得登入
|
||||
- Web cookie 與 API authenticated request 會檢查 disabled 狀態
|
||||
- 已補 redirect 型登入 `web_login`:
|
||||
- OAuth client usage 新增 `web_login`
|
||||
- 支援 Authorization Code + PKCE
|
||||
- `/oauth/authorize` 未登入時會導向 Web login,登入後回到原 authorize request
|
||||
- `/oauth/token` 已支援 authorization code exchange
|
||||
- API / Web 共用 DataProtection application name `MemberCenter`
|
||||
- 支援 `Auth:WebLoginUrl` 與 `Auth:AllowedLoginReturnUrlPrefixes` 處理 Web / API 不同 origin 的 redirect login
|
||||
|
||||
進行中:
|
||||
- profile / addresses / subscriptions 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示
|
||||
|
||||
待續作:
|
||||
- resource registry 管理 UI
|
||||
- Email 驗證信 / 重設信的正式模板與文案優化
|
||||
- rate limit 與 lockout 規則補齊:
|
||||
- one-click unsubscribe token 申請
|
||||
- 服務型 token flow 與人類登入 flow 的完整差異化治理
|
||||
- 更細的風控觀測與後台設定化
|
||||
- audit log 與 rate limit 尚未全面覆蓋所有規劃入口與治理事件
|
||||
- `superuser` / `admin` 第一版已完成,後續待細化:
|
||||
- 更細權限切分
|
||||
- 是否需要更多治理角色
|
||||
- 管理後台帳號治理功能補強:
|
||||
- 更完整排序 / 篩選進一步細化
|
||||
- 更細的操作確認與保護規則擴充
|
||||
|
||||
## 現況盤點
|
||||
|
||||
### 已存在
|
||||
- `MemberCenter.Web` 與 `MemberCenter.Api` 已有本地註冊、登入、忘記密碼、重設密碼、Email 驗證入口。
|
||||
- `MemberCenter.Web/Controllers/AccountController.cs` 已有:
|
||||
- `ForgotPassword`
|
||||
- `ResetPassword`
|
||||
- `VerifyEmail`
|
||||
- `ExternalLogin` / `ExternalLoginCallback`
|
||||
- `MemberCenter.Api/Controllers/AuthController.cs` 已有:
|
||||
- `POST /auth/register`
|
||||
- `POST /auth/password/forgot`
|
||||
- `POST /auth/password/reset`
|
||||
- `GET /auth/email/verify`
|
||||
- Google external login 已在 `src/MemberCenter.Web/Program.cs` 接入,並已支援同 email auto-link。
|
||||
- Installer 已可建立初始管理帳號,但角色模型仍需調整為文件定義的 `superuser`。
|
||||
- 管理後台已有 `/admin/security` 畫面,目前僅提供 token 時效設定:
|
||||
- `AccessTokenMinutes`
|
||||
- `RefreshTokenDays`
|
||||
- `MemberCenter.Web` 已有 `/profile` 頁面,但目前僅顯示:
|
||||
- Email
|
||||
- Email 驗證狀態
|
||||
- 建立時間
|
||||
- `MemberCenter.Api` 已有 `GET /user/profile`,但目前只提供基礎欄位,不足以支撐其他服務查詢完整會員資料。
|
||||
- `docs/DESIGN.md` / `docs/FLOWS.md` 已定義 File Access 流程:
|
||||
- upload: `client_credentials` 取得 upload token 後由外部服務直連 file space
|
||||
- download: 由業務服務驗權後,向 Member Center 申請短效 delegated download token,File Access agent 回打 Member Center 驗證
|
||||
- Auth resource registry 第一版已落地:
|
||||
- 新增 `auth_resources`、`auth_resource_scopes`、`auth_client_usage_permissions`
|
||||
- `TokenController` 已改由 scope 查 registry 決定 token audiences
|
||||
- OAuth client usage 可使用的 scopes 已改由 DB permission matrix 決定
|
||||
- 預設資源包含 `member_center_api`、`send_engine_api`、`file_access_api`
|
||||
|
||||
### 部分實作
|
||||
- `SendEngine__BaseUrl`、`SendEngine__WebhookSecret` 仍停留在設定來源與 options binding,尚未進入可編輯的管理畫面。
|
||||
- Auth 資源 / audience / scope registry 已完成 DB 驅動第一版,仍缺管理 UI。
|
||||
- 帳號治理後台、角色模型與 disabled account 規則已完成第一版,但仍缺進一步細化與保護規則。
|
||||
- profile / addresses / subscriptions 畫面與驗證目前為最小可用版本,尚未完成 UI refinement。
|
||||
|
||||
### 待補項
|
||||
- File Access 的 OAuth client usage、scope、audience 已落地;delegated download token issuing / validation 已落在 Member Center。
|
||||
- Token resource / audience 已抽象為 registry;後續需補 resource registry 管理畫面。
|
||||
- Email 樣板正式文案與會員 / 後台 UI 細節仍待整理。
|
||||
- rate limit 仍缺少 `one-click unsubscribe token` 與更細的風控觀測。
|
||||
|
||||
## 功能規劃
|
||||
|
||||
### 1. 系統設定畫面
|
||||
狀態:`部分完成`
|
||||
|
||||
#### 1.1 主要新增項
|
||||
- 新增獨立的「系統設定」或「整合設定」畫面。
|
||||
- 第一批可編輯設定:
|
||||
- SMTP Host
|
||||
- SMTP Port
|
||||
- SMTP Username
|
||||
- SMTP Password
|
||||
- SMTP From Name
|
||||
- SMTP From Address
|
||||
- SMTP Enable SSL / TLS
|
||||
- `SendEngine__BaseUrl`
|
||||
- `SendEngine__WebhookSecret`
|
||||
|
||||
目前進度:
|
||||
- 已完成 SMTP 與 token lifetime 設定 UI,沿用 `/admin/security`
|
||||
- 已完成 SMTP 測試信
|
||||
- 已完成 `PublicBaseUrl`
|
||||
- `SendEngine__BaseUrl` / `SendEngine__WebhookSecret` 尚未進管理畫面
|
||||
- Auth 資源設定暫未實作,仍保留待 audience/scope 抽象化後處理
|
||||
|
||||
#### 1.2 可一併納入的既有設定
|
||||
- 目前 `/admin/security` 已有的 token 時效設定可整併進同一個設定體系:
|
||||
- Access token 分鐘數
|
||||
- Refresh token 天數
|
||||
- `SendEngineWebhookOptions.SubscriptionEventsPath` 目前已有預設值 `/webhooks/subscriptions`,若未來有多環境或反向代理差異,可評估是否也納入設定畫面。
|
||||
|
||||
#### 1.3 暫不建議放入設定畫面的項目
|
||||
- `ConnectionStrings__Default`
|
||||
- `Auth__Issuer`
|
||||
- `PathBase`
|
||||
- Google OAuth Client Secret
|
||||
|
||||
以上屬部署層級或高風險設定,建議仍以環境變數或部署設定管理,不在一般管理 UI 直接編輯。
|
||||
|
||||
#### 1.4 設定保存策略
|
||||
- 所有可運行期調整的設定統一收斂到 DB,例如沿用 `system_flags` 或新增專用設定表。
|
||||
- 敏感值至少需要:
|
||||
- UI 遮罩顯示
|
||||
- 審計紀錄
|
||||
- 更新權限限制
|
||||
- Secret 類設定需明確區分:
|
||||
- 可明文顯示的設定
|
||||
- 僅能覆寫、不可回顯的 secret 類設定
|
||||
|
||||
### 2. Email 驗證與忘記密碼
|
||||
狀態:`核心完成,文案待續作`
|
||||
|
||||
#### 2.1 目標狀態
|
||||
- 註冊後自動寄送 Email 驗證信。
|
||||
- 使用者可重新發送驗證信。
|
||||
- 忘記密碼送出後寄送 reset password email。
|
||||
- Web UI 與 API 共享同一套 token 與寄信服務邏輯。
|
||||
|
||||
#### 2.2 目前已有的基礎
|
||||
- Identity token provider 已可產生:
|
||||
- email confirmation token
|
||||
- password reset token
|
||||
- Web 與 API 已有 verify/reset endpoint。
|
||||
- View 已存在:
|
||||
- `ForgotPassword`
|
||||
- `ResetPassword`
|
||||
- `VerifyEmailResult`
|
||||
|
||||
#### 2.3 待補實作
|
||||
- Email 樣板正式文案整理:
|
||||
- 驗證信
|
||||
- 忘記密碼信
|
||||
- 更完整的產品提示與畫面細節整理
|
||||
|
||||
#### 2.4 安全與產品規則
|
||||
- 忘記密碼 API 與 UI 都應避免暴露帳號是否存在。
|
||||
- Reset token 與 verify token 的 URL 應統一由設定的 public base URL 組出。
|
||||
- 驗證信與重設密碼信都應記錄 audit log。
|
||||
- 會員未驗證時是否允許登入與可操作範圍,需在實作時明確固定為單一規則,不保留模糊狀態。
|
||||
|
||||
### 3. 帳號分級與角色管理
|
||||
狀態:`第一版完成,細化待續作`
|
||||
|
||||
#### 3.1 角色模型
|
||||
- `superuser`
|
||||
- 只能透過 installer 建立或提升。
|
||||
- 可管理所有帳號角色。
|
||||
- 可授予或移除 `admin`。
|
||||
- `admin`
|
||||
- 必須先以一般會員身分註冊。
|
||||
- 再由 `superuser` 手動授權。
|
||||
- 不可自行提升其他帳號為 `admin`,除非後續明確擴權。
|
||||
- `member`
|
||||
- 預設註冊身分。
|
||||
- 依 Email 驗證狀態再分為:
|
||||
- `unverified`
|
||||
- `verified`
|
||||
|
||||
#### 3.2 會員分級原則
|
||||
- 會員狀態以「已認證 / 未認證」為基礎。
|
||||
- 若未來擴充,可在不破壞既有角色模型下新增:
|
||||
- VIP / 付費會員
|
||||
- 停權 / 凍結
|
||||
- 企業帳號等級
|
||||
|
||||
#### 3.3 對現行實作的調整方向
|
||||
- 已完成:
|
||||
- Installer 建立的高權限帳號改為 `superuser`
|
||||
- 管理後台中的角色治理操作為 `superuser` 專屬
|
||||
- 已新增 `/admin/accounts`,可查詢帳號、查看驗證狀態、指派或移除 `admin`、顯示 `superuser`
|
||||
- 已補搜尋與篩選
|
||||
- 待續作:
|
||||
- 更細的角色切分與保護規則
|
||||
|
||||
#### 3.4 權限規則
|
||||
- 只有 `superuser` 可變更角色。
|
||||
- `admin` 可進入既有管理後台,但不可升降他人權限。
|
||||
- 一般會員不可看見 `/admin/*`。
|
||||
- 未來若導入更多後台功能,建議區分:
|
||||
- 後台使用權限
|
||||
- 帳號治理權限
|
||||
|
||||
### 3.5 帳號生命週期
|
||||
狀態:`部分完成`
|
||||
- 帳號狀態至少包含:
|
||||
- `active`
|
||||
- `disabled`
|
||||
- `locked`
|
||||
- Email 驗證狀態獨立於帳號狀態,維持:
|
||||
- `unverified`
|
||||
- `verified`
|
||||
- Email 為對外主識別 key,不允許修改。
|
||||
- 若使用者要更換 email,採重新註冊新帳號,不提供原帳號改 email 流程。
|
||||
- 後台需保留帳號停用能力;停用後不得再登入與取得新 token。
|
||||
- 建議記錄帳號治理欄位:
|
||||
- `last_login_at`
|
||||
- `last_seen_at`
|
||||
- `disabled_at`
|
||||
- `disabled_by`
|
||||
- superuser 重設他人密碼、停用帳號、解除停用等治理規則,先記入規劃,細節待後續檢討。
|
||||
|
||||
目前進度:
|
||||
- 已完成停用 / 啟用帳號
|
||||
- 已完成 disabled 帳號不可登入與不可取得新 token
|
||||
- 已完成 `last_login_at` / `last_seen_at` / `disabled_at` / `disabled_by`
|
||||
- 已完成 superuser 重設非 superuser 帳號密碼,並強制讓既有 session / refresh token 失效
|
||||
- superuser 本身的密碼治理仍限定走 installer
|
||||
|
||||
### 4. 第三方登入
|
||||
狀態:`已完成本期範圍`
|
||||
- 只支援 Google。
|
||||
- 目前 Google external login 已存在,後續只補齊與整體權限模型的一致性。
|
||||
- 若 Google 回傳 email 已驗證,可直接把會員標記為 `verified`;目前 `AccountProvisioningService` 已有依 external login provider 回填 `EmailConfirmed` 的基礎邏輯。
|
||||
|
||||
### 5. 會員基本資料與地址簿
|
||||
狀態:`核心完成,UI refinement 待續作`
|
||||
|
||||
#### 5.1 會員基本資料
|
||||
- 會員可自行維護的基本資料欄位包含:
|
||||
- `last_name`
|
||||
- `first_name`
|
||||
- `nick_name`
|
||||
- `mobile_phone`
|
||||
- `landline_phone`
|
||||
- `date_of_birth`
|
||||
- `gender`
|
||||
- 公司名稱
|
||||
- 部門
|
||||
- 職稱
|
||||
- 公司電話
|
||||
- 統一編號
|
||||
- 發票抬頭
|
||||
- `remark`
|
||||
- Email 為固定欄位,不提供變更流程;如需更換 email,採重新註冊。
|
||||
- Email 同時作為對外 API 的主 key。
|
||||
|
||||
建議必填欄位:
|
||||
- `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`
|
||||
|
||||
建議 enum:
|
||||
- `gender`
|
||||
- `male`
|
||||
- `female`
|
||||
- `other`
|
||||
- `unspecified`
|
||||
|
||||
欄位驗證建議:
|
||||
- `first_name`、`last_name`
|
||||
- 必填
|
||||
- 長度 `1-100`
|
||||
- `nick_name`
|
||||
- 長度 `0-100`
|
||||
- `mobile_phone`、`landline_phone`、`company_phone`
|
||||
- 儲存標準化字串
|
||||
- 盡量接近 E.164,市話可接受本地格式輸入後正規化
|
||||
- `company_name`、`department`、`job_title`
|
||||
- 長度 `0-200`
|
||||
- `tax_id`
|
||||
- 長度 `0-32`
|
||||
- 驗證以台灣統編規則為主,但 schema 保留國際延展空間
|
||||
- `invoice_title`
|
||||
- 長度 `0-200`
|
||||
- `remark`
|
||||
- 長度 `0-1000`
|
||||
- `date_of_birth`
|
||||
- 使用 `date` 型別,不使用 datetime
|
||||
|
||||
資料使用原則:
|
||||
- `first_name + last_name` 為正式姓名來源
|
||||
- `nick_name` 為顯示用途,不作為唯一鍵
|
||||
- `mobile_phone` 優先於 `landline_phone` 作為主要聯絡電話
|
||||
|
||||
目前進度:
|
||||
- 上述欄位與資料模型、API、Web profile 編輯已完成第一版
|
||||
- 欄位驗證與畫面體驗仍待整理
|
||||
|
||||
#### 5.2 地址簿 / 收貨地址清單
|
||||
- 新增會員地址簿概念,支援一位會員維護多筆地址。
|
||||
- 地址欄位包含:
|
||||
- `label`
|
||||
- 收件人姓名
|
||||
- 收件人電話
|
||||
- `country_code`
|
||||
- `postal_code`
|
||||
- `state_region`
|
||||
- `city`
|
||||
- `district`
|
||||
- `address_line1`
|
||||
- `address_line2`
|
||||
- 公司名稱(可選)
|
||||
- 是否預設地址
|
||||
- 地址用途
|
||||
- `address_meta_json`
|
||||
- 地址用途建議先預留列舉:
|
||||
- `shipping`
|
||||
- `billing`
|
||||
- `both`
|
||||
- 資料格式規則:
|
||||
- 以台灣使用情境為主
|
||||
- 國碼、電話、郵遞區號、國家碼等欄位盡量符合國際格式
|
||||
- 統編以台灣統編規則為主
|
||||
- 地址採「結構化欄位 + `address_meta_json` 補充資料」混合設計,不採純 JSON
|
||||
- 刪除規則:
|
||||
- 若會員只剩最後一筆地址,不允許刪除
|
||||
- 若刪除的是預設地址,系統需自動補選新的預設地址
|
||||
|
||||
建議必填欄位:
|
||||
- `user_id`
|
||||
- `label`
|
||||
- `recipient_name`
|
||||
- `recipient_phone`
|
||||
- `country_code`
|
||||
- `address_line1`
|
||||
- `usage`
|
||||
- `is_default`
|
||||
|
||||
建議選填欄位:
|
||||
- `postal_code`
|
||||
- `state_region`
|
||||
- `city`
|
||||
- `district`
|
||||
- `address_line2`
|
||||
- `company_name`
|
||||
- `address_meta_json`
|
||||
|
||||
建議 enum:
|
||||
- `usage`
|
||||
- `shipping`
|
||||
- `billing`
|
||||
- `both`
|
||||
|
||||
欄位驗證建議:
|
||||
- `label`
|
||||
- 長度 `1-100`
|
||||
- `recipient_name`
|
||||
- 必填
|
||||
- 長度 `1-100`
|
||||
- `recipient_phone`
|
||||
- 必填
|
||||
- 儲存標準化字串
|
||||
- `country_code`
|
||||
- 必填
|
||||
- 使用 ISO 3166-1 alpha-2
|
||||
- 固定 2 碼大寫
|
||||
- `postal_code`
|
||||
- 長度 `0-20`
|
||||
- `state_region`、`city`、`district`
|
||||
- 長度各 `0-100`
|
||||
- `address_line1`
|
||||
- 必填
|
||||
- 長度 `1-255`
|
||||
- `address_line2`
|
||||
- 長度 `0-255`
|
||||
- `company_name`
|
||||
- 長度 `0-200`
|
||||
- `address_meta_json`
|
||||
- 可為 `null`
|
||||
|
||||
資料規則:
|
||||
- 同一個 `user_id + usage` 最好限制只能有一筆 `is_default = true`
|
||||
- `label` 可為自由文字,不先做 enum
|
||||
- 地址至少保留一筆,因此最後一筆不可刪除
|
||||
|
||||
目前進度:
|
||||
- 地址簿資料模型、API、Web UI 已完成第一版
|
||||
- `最後一筆不可刪除` 已實作
|
||||
- 預設地址切換與資料結構已完成
|
||||
- 畫面提示與驗證細節仍待整理
|
||||
|
||||
#### 5.3 資料治理原則
|
||||
- 會員基本資料與地址簿屬於會員中心主資料,可供其他服務查詢,但寫入權限需嚴格控管。
|
||||
- 會員本人可編輯自己的基本資料與地址簿。
|
||||
- 其他服務預設只有讀取權限。
|
||||
- 若其他服務需要代會員寫入,必須有額外 scope 與審計規則。
|
||||
|
||||
### 6. 會員資料 API 與 Auth Scope 規範
|
||||
狀態:`scope 已落地,資源抽象化待續作`
|
||||
|
||||
#### 6.1 規劃目的
|
||||
- 讓其他服務可透過 API 取得會員中心的基本資料與地址資料。
|
||||
- 在一開始就把 scope 邊界定清楚,避免未來 profile API 無限制外放。
|
||||
|
||||
#### 6.2 建議 scopes
|
||||
- `profile:basic.read`
|
||||
- 讀取會員基本資料
|
||||
- `profile:basic.write`
|
||||
- 更新會員基本資料
|
||||
- `profile:addresses.read`
|
||||
- 讀取會員地址簿
|
||||
- `profile:addresses.write`
|
||||
- 新增、修改、刪除會員地址簿
|
||||
- `profile:subscriptions.read`
|
||||
- 讀取會員已訂閱電子報清單
|
||||
- `profile:subscriptions.write`
|
||||
- 透過已登入會員介面取消訂閱或調整會員自己的訂閱狀態
|
||||
|
||||
目前進度:
|
||||
- `profile:*` scopes 已註冊並接上 policy
|
||||
- current-user 與 by-email service API 已完成第一版
|
||||
- audience / resource registry DB 驅動第一版已完成
|
||||
|
||||
#### 6.3 API 邊界建議
|
||||
- 其他服務 API:
|
||||
- 目前規劃以 service API 為主
|
||||
- 只要 Auth 設定有授與對應 scope,該服務即可存取對應資料
|
||||
- 讀寫能力完全由 scope 控制
|
||||
- 預設以最小權限授權,不因 client 類型自動放寬資料邊界
|
||||
- 會員本人 UI / API:
|
||||
- 可讀寫自己的 profile 與地址
|
||||
- 可查看自己的訂閱清單
|
||||
- 可對自己的訂閱直接退訂,不需再次 email token 確認
|
||||
- 管理後台:
|
||||
- 可查詢會員資料,但是否可編輯應另行定義,不與一般會員本人編輯權混用
|
||||
|
||||
#### 6.4 與 OIDC 標準 scope 的關係
|
||||
- 目前 `openid email profile` 中的 `profile` scope 太寬泛,不足以承載業務上需要的個資與地址簿授權控制。
|
||||
- 建議保留 OIDC `profile` 作為基本 claims 用途,但業務資料改由自訂 scope 控制。
|
||||
|
||||
### 7. 會員端訂閱管理(我的訂閱)
|
||||
狀態:`核心完成,newsletter 補強待續作`
|
||||
|
||||
#### 7.1 目標
|
||||
- 會員登入後,可集中查看自己目前已訂閱的電子報清單。
|
||||
- 會員可直接從此介面取消訂閱,不需要再次透過 email token 驗證。
|
||||
- 此流程與 email 內的一鍵退訂屬不同入口:
|
||||
- email 退訂:適用未登入或直接從信件操作
|
||||
- 會員中心退訂:適用已登入且已確認是本人
|
||||
|
||||
#### 7.2 UI 能力
|
||||
- 顯示已訂閱清單:
|
||||
- 所屬 tenant / 站台
|
||||
- 電子報清單名稱
|
||||
- 訂閱狀態
|
||||
- 訂閱建立時間
|
||||
- 最後更新時間
|
||||
- 可執行:
|
||||
- 直接取消訂閱
|
||||
- 未來可擴充為偏好調整或重新訂閱
|
||||
|
||||
目前進度:
|
||||
- 我的訂閱頁與 current-user 訂閱 API 已完成
|
||||
- 會員登入後可直接退訂,不需再次 email token
|
||||
- 進一步的 newsletter UI / 行為補強本輪刻意先跳過
|
||||
|
||||
#### 7.3 流程規則
|
||||
- 已登入會員對自己的訂閱執行退訂時,不需 email token 二次確認。
|
||||
- 系統仍需:
|
||||
- 驗證 subscription 確實屬於目前登入會員
|
||||
- 寫入 audit log
|
||||
- 發送 `subscription.unsubscribed` 事件
|
||||
- 若該 email 已在黑名單中,行為需與既有退訂規則一致。
|
||||
- 即使已綁定 `user_id`,仍保留既有以 `list_id + email` 進行訂閱 / 退訂的 public 流程。
|
||||
- 會員可重新訂閱已退訂的電子報,規則與既有 public subscribe 流程保持一致。
|
||||
|
||||
#### 7.4 與現行訂閱 API 的差異
|
||||
- 現行 `/newsletter/preferences` 偏向以 `list_id + email` 操作,主要照顧跨站 API 邊界。
|
||||
- 新的「我的訂閱」介面應改以登入者身份為主體,不再要求使用者自行輸入 email。
|
||||
- 建議新增一組以 current user 為主體的 API,而不是把既有未登入流程硬改成同一支。
|
||||
|
||||
### 7.5 訂閱識別原則
|
||||
- Email 是訂閱領域的主 key。
|
||||
- `user_id` 為已註冊會員與訂閱資料的關聯鍵,不取代 email 在訂閱流程中的角色。
|
||||
- 因此系統同時保留:
|
||||
- public flow:`list_id + email`
|
||||
- member flow:`current user`
|
||||
- 兩種入口最終都回到同一組 subscription 資料。
|
||||
|
||||
### 8. 會員中心作為外部服務的 Token / Auth 中心
|
||||
狀態:`Auth 基礎已完成,管理 UI 待補`
|
||||
|
||||
#### 8.1 共通模型
|
||||
- Send Engine 與 File Access 本質上是同一套模型:
|
||||
- 由 Member Center 簽發 access token
|
||||
- 外部服務以 Member Center JWKS 驗簽
|
||||
- 依 `iss/aud/exp/scope/tenant_id` 做授權與租戶邊界控制
|
||||
- 差異只在資源類型不同:
|
||||
- Send Engine 偏向 service-to-service API 呼叫
|
||||
- File Access 除了 upload 的 S2S token 外,download 還需要 delegated short-lived token
|
||||
|
||||
#### 8.2 File Access 納入規劃
|
||||
- 新增 OAuth client usage,建議至少區分:
|
||||
- `file_api`
|
||||
- 或更明確拆成 `file_upload_api` / `file_download_delegate`
|
||||
- 新增 scopes:
|
||||
- `files:upload.write`
|
||||
- `files:download.read`
|
||||
- `files:download.delegate`
|
||||
- `files:delete`
|
||||
- `files:metadata.read`
|
||||
- 新增 audience:
|
||||
- `file_access_api`
|
||||
- Member Center 需實作 delegated download token 規則:
|
||||
- 下載 token 必須短效
|
||||
- 必須綁定 `tenant_id`
|
||||
- 必須綁定 `user_id`
|
||||
- 必須綁定 `file_id` 或 `object_key`
|
||||
- 必須綁定 `method`
|
||||
- 不可直接重用一般 S2S access token 給 client
|
||||
|
||||
#### 8.3 目標設計
|
||||
- Auth 資源設定採 resource registry,不再以一組一組 `Auth__XAudience` 擴充。
|
||||
- 每個外部資源服務都以統一結構註冊:
|
||||
- resource name
|
||||
- audience
|
||||
- scopes
|
||||
- client usages
|
||||
- 是否需要 `tenant_id`
|
||||
- 是否允許 delegated token(供 Member Center 判斷該 resource 是否可簽發短效下載 token)
|
||||
- 新增資源服務時,只擴充 registry 與授權規則,不再修改硬編碼 audience 分支。
|
||||
- 所有外部資料存取均以 scope 作為唯一授權依據。
|
||||
|
||||
#### 8.4 授權模型
|
||||
- OAuth client usage 與 resource 綁定:
|
||||
- `tenant_api` -> `member_center_api`
|
||||
- `send_api` -> `send_engine_api`
|
||||
- `file_api` -> `file_access_api`
|
||||
- scope 用於細粒度權限控制。
|
||||
- `TokenController` 依 scope 與 usage 對照 resource registry 計算 `resources/audiences`。
|
||||
- delegated download token issuing / validation 屬於 Member Center 責任;File Access agent 負責把 client GET request 的 token 與 `tenant_id + file_id/object_key + method` 帶回 Member Center 驗證。
|
||||
|
||||
目前進度:
|
||||
- Send Engine、Member Center profile/newsletter scopes、File Access scopes 已進 registry
|
||||
- `TokenController` 已以 registry 解析 audiences
|
||||
- OAuth client usage-scope matrix 已以 `auth_client_usage_permissions` 驅動
|
||||
- File Access delegated token issuing / validation API 已落在 Member Center;File Access agent 尚需串接 validation endpoint
|
||||
|
||||
### 9. 審計紀錄
|
||||
狀態:`大致完成,少數治理事件待續作`
|
||||
- 下列事件必須寫入 audit log:
|
||||
- 註冊
|
||||
- Email 驗證成功
|
||||
- 忘記密碼申請
|
||||
- 密碼重設成功
|
||||
- 已登入修改密碼
|
||||
- Google external login 綁定
|
||||
- 角色變更
|
||||
- 帳號停用 / 啟用
|
||||
- profile 修改
|
||||
- 地址簿新增 / 編輯 / 刪除 / 預設地址切換
|
||||
- 會員端直接退訂
|
||||
- 系統設定修改
|
||||
- OAuth client 建立
|
||||
- OAuth client secret 旋轉
|
||||
- OAuth client secret 顯示一次、旋轉與治理能力視為既有基線;細節之後再檢討。
|
||||
|
||||
目前進度:
|
||||
- 帳號寄信、驗證、重設密碼、修改密碼、註冊、external login 綁定、角色變更、帳號停用 / 啟用、profile、地址、會員端退訂、系統設定修改均已有實作
|
||||
- OAuth client 建立與 secret 旋轉等治理細節仍待續作
|
||||
|
||||
### 10. Rate Limit 與防濫用
|
||||
狀態:`部分完成`
|
||||
- 下列入口必須有 rate limit:
|
||||
- login
|
||||
- forgot password
|
||||
- resend verification
|
||||
- register
|
||||
- public subscribe
|
||||
- unsubscribe token 申請
|
||||
- one-click unsubscribe token 申請
|
||||
- lockout 與 rate limit 需能區分:
|
||||
- 人類使用者登入
|
||||
- service API token 申請
|
||||
|
||||
目前進度:
|
||||
- 已完成 login / forgot password / resend verification / register / public subscribe / unsubscribe token 申請
|
||||
- `one-click unsubscribe token` 申請仍待補
|
||||
- 人類登入 flow 已有 lockout;service API token flow 與更細觀測仍待續作
|
||||
|
||||
### 11. MFA 與非本期項目
|
||||
狀態:`待續作`
|
||||
- 在 Email 寄送能力完成後,可規劃 Email-based MFA / challenge 驗證。
|
||||
- 其餘先明確列為未來可補項:
|
||||
- consent / terms acceptance
|
||||
- 資料匯出 / 刪除
|
||||
- login history / device management
|
||||
- 通知偏好中心
|
||||
|
||||
## 一次到位的實作範圍
|
||||
1. 設定中心:
|
||||
- SMTP
|
||||
- Send Engine
|
||||
- Auth 資源設定
|
||||
- token lifetime
|
||||
2. Identity 與通知:
|
||||
- Email 驗證寄信
|
||||
- 忘記密碼寄信
|
||||
- 重送驗證信
|
||||
3. 會員主資料:
|
||||
- 基本資料
|
||||
- 地址簿
|
||||
- Profile UI / API
|
||||
4. 訂閱管理:
|
||||
- 我的訂閱頁
|
||||
- current-user 型訂閱 API
|
||||
- 直接退訂流程
|
||||
5. 權限與角色:
|
||||
- `superuser`
|
||||
- `admin`
|
||||
- `member` + verified / unverified
|
||||
- 帳號管理與角色指派
|
||||
6. Auth 中心:
|
||||
- resource registry
|
||||
- profile scopes
|
||||
- file access scopes
|
||||
- delegated token
|
||||
7. 安全治理:
|
||||
- audit log
|
||||
- rate limit
|
||||
- MFA 預留
|
||||
8. 文件、測試與 installer 同步調整
|
||||
|
||||
目前整體狀態:
|
||||
- `1`:部分完成
|
||||
- `2`:核心完成,文案待續作
|
||||
- `3`:核心完成
|
||||
- `4`:核心完成,但本輪不再補強 newsletter refinement
|
||||
- `5`:第一版完成
|
||||
- `6`:先跳過 audience / scope 抽象化
|
||||
- `7`:大致完成,仍有少量補強
|
||||
- `8`:部分完成
|
||||
|
||||
## 影響範圍
|
||||
|
||||
### Web
|
||||
- 新增設定畫面與表單。
|
||||
- 新增帳號管理畫面。
|
||||
- 調整註冊成功後導引與驗證提醒文案。
|
||||
- 新增會員 profile 編輯頁、地址簿頁與我的訂閱頁。
|
||||
|
||||
### API
|
||||
- 若設定畫面走 API,需新增設定讀寫端點。
|
||||
- 若帳號管理後台走 API,需新增角色管理與帳號查詢端點。
|
||||
- File Access 的 resource / audience mapping、file scopes、delegated token issuing / validation API 已完成;File Access agent 串接與流程測試仍待補。
|
||||
- 需新增 current-user 型 profile / addresses / subscriptions API 與對應 scopes。
|
||||
|
||||
### Infrastructure
|
||||
- 新增 SMTP sender 與設定存取服務。
|
||||
- 調整帳號 provisioning 與角色管理服務。
|
||||
- token resource resolver 已抽到 registry service;File Access delegated token issuer / validator 已落在 Member Center。
|
||||
- 新增會員基本資料與地址簿的資料模型與服務層。
|
||||
|
||||
### Installer
|
||||
- 建立或提升 `superuser`。
|
||||
- 視命名策略決定是否保留 `add-admin`,或改名為更貼近語意的指令。
|
||||
|
||||
## 文件與實作同步原則
|
||||
- 文件中的「已存在」代表已有 controller / route / view / 基礎邏輯,不代表整體功能已完成產品化。
|
||||
- 本規劃完成後,應同步回寫:
|
||||
- `docs/UI.md`
|
||||
- `docs/FLOWS.md`
|
||||
- `docs/INSTALL.md`
|
||||
- `README.md`
|
||||
@ -81,7 +81,7 @@ curl -s -X POST https://{send-engine}/api/send-jobs \
|
||||
- Send Engine 以 Member Center 的 JWKS 驗簽 access token(JWS)。
|
||||
- 驗證重點:`iss`、`aud`、`scope`、`tenant_id`、`exp`。
|
||||
- `iss`:由 Member Center `Auth__Issuer` 設定(例:`http://localhost:7850/`)
|
||||
- `aud`:Send Engine 流程預設 `send_engine_api`,由 Auth resource registry 決定;舊版 `Auth__SendEngineAudience` 仍作為相容 seed 來源
|
||||
- `aud`:Send Engine 流程預設 `send_engine_api`(可用 `Auth__SendEngineAudience` 覆寫)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -72,16 +72,6 @@
|
||||
- `usage=send_api`:
|
||||
- 供租戶站台呼叫 Send Engine 發信流程
|
||||
- 內建 scope:`newsletter:send.write`、`newsletter:send.read`
|
||||
- `usage=file_api`:
|
||||
- 供檔案上傳 / 下載服務使用
|
||||
- 使用 `client_credentials`
|
||||
- 內建 scope:`files:upload.write`、`files:download.read`、`files:download.delegate`、`files:metadata.read`、`files:delete`
|
||||
- `usage=web_login`:
|
||||
- 供外部網站使用 Member Center 統一登入 UI
|
||||
- 使用 Authorization Code + PKCE
|
||||
- 需設定 `redirect_uris`
|
||||
- 可使用 `client_type=public`
|
||||
- 允許 scope:`openid`、`email`、`profile`、`profile:basic.read`
|
||||
- `usage=webhook_outbound`:
|
||||
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
|
||||
- 不可用於租戶 API 呼叫
|
||||
@ -90,8 +80,8 @@
|
||||
- `usage=platform_service`:
|
||||
- 供平台級 S2S(例如 SES 聚合事件回寫)
|
||||
- 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global`
|
||||
- `tenant_api` / `send_api` / `platform_service` / `file_api` 建議(且實作要求)`client_type=confidential`
|
||||
- `redirect_uris` 僅 `web_login` / `webhook_outbound` 需要;其他 usage 可為空
|
||||
- `tenant_api` / `send_api` / `platform_service` 建議(且實作要求)`client_type=confidential`
|
||||
- `redirect_uris` 僅 `webhook_outbound` 需要;其他 usage 可為空
|
||||
- 管理規則:
|
||||
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
||||
- 平台級流程另建 `platform_service` 憑證
|
||||
@ -108,7 +98,7 @@
|
||||
- `POST /newsletter/one-click-unsubscribe-tokens`:已實作(Send Engine 批次申請 one-click token)
|
||||
|
||||
### Auth / Scope
|
||||
- `tenant_api` / `send_api` / `file_api` / `webhook_outbound` 類型需綁定 `tenant_id`
|
||||
- `tenant_api` / `send_api` / `webhook_outbound` 類型需綁定 `tenant_id`
|
||||
- `platform_service` 可不綁定 `tenant_id`
|
||||
- 新增 scope:
|
||||
- `newsletter:list.read`
|
||||
@ -117,22 +107,6 @@
|
||||
- `newsletter:events.read`
|
||||
- `newsletter:events.write`
|
||||
- `newsletter:events.write.global`
|
||||
- 規劃新增 profile scopes:
|
||||
- `profile:basic.read`
|
||||
- `profile:basic.write`
|
||||
- `profile:addresses.read`
|
||||
- `profile:addresses.write`
|
||||
- `profile:subscriptions.read`
|
||||
- `profile:subscriptions.write`
|
||||
- profile 相關 API 以 service API 為主要整合模式,授權完全由 scope 控制
|
||||
- 規劃新增 file access scopes:
|
||||
- `files:upload.write`
|
||||
- `files:download.read`
|
||||
- `files:download.delegate`
|
||||
- `files:delete`
|
||||
- `files:metadata.read`
|
||||
- 規劃新增 audience:
|
||||
- `file_access_api`
|
||||
- 發送引擎僅能用上述 scope,禁止 admin 權限
|
||||
- `POST /subscriptions/disable` 需 Bearer token 且包含下列其一:
|
||||
- `newsletter:events.write`(tenant-scoped)
|
||||
@ -140,48 +114,9 @@
|
||||
- 建議 Send Engine 使用 client credentials 取 token,不建議使用長效固定 token
|
||||
- Send Engine 建議以 JWKS 驗簽 JWT(JWS),並驗證 `scope/tenant_id/exp`
|
||||
- `iss` 由 `Auth:Issuer` 設定(例:`http://localhost:7850/`)
|
||||
- `aud` 由 Auth resource registry 決定,而非每個服務各自新增一組獨立設定
|
||||
- 標準資源包含:
|
||||
- Send Engine 流程:`send_engine_api`
|
||||
- Member Center API 流程:`member_center_api`
|
||||
- File access 流程:`file_access_api`
|
||||
- `TokenController` 依 scope / usage 對照 resource registry 計算 `aud`
|
||||
|
||||
### File Access Auth(A service / client / access agent)
|
||||
|
||||
用途:
|
||||
- `A service` 上傳檔案到 file space
|
||||
- `A service` 對 client 發放短效下載 URL
|
||||
- access agent 依 token 決定是否放行上傳或下載
|
||||
|
||||
規則:
|
||||
- Upload 採 S2S:
|
||||
- `A service` 使用 `client_credentials`
|
||||
- token 至少需帶 `files:upload.write`
|
||||
- token 應帶 `tenant_id`
|
||||
- access agent 驗 `iss/aud/exp/scope/tenant_id`
|
||||
- Download 採 delegated short-lived token:
|
||||
- 不直接將一般 S2S token 暴露給 client
|
||||
- `A service` 先以自身商業邏輯驗證 Member Center user 身份與檔案權限
|
||||
- `A service` 以 `files:download.delegate` 向 Member Center 申請短效 download token
|
||||
- access agent 以 `files:download.read` 回打 Member Center 驗證 token 是否有效
|
||||
- 下載 token 至少需帶:
|
||||
- `tenant_id`
|
||||
- `user_id`
|
||||
- `file_id` 或 `object_key`
|
||||
- `method=GET`
|
||||
- 短效 `exp`
|
||||
- 建議 `jti`
|
||||
- access agent 至少應送回 Member Center 驗:
|
||||
- opaque download token
|
||||
- `tenant_id`
|
||||
- token 內檔案識別與實際 request 是否一致
|
||||
- token 內 method 與實際 request 是否一致
|
||||
|
||||
補充:
|
||||
- File Access 與 Send Engine 同屬「外部資源服務」,驗證模型一致
|
||||
- 差異在於 File Access download token 為 Member Center 簽發的 delegated short-lived opaque token,而非一般 client credentials token
|
||||
- Member Center 負責 delegated download token issuing 與 validation;File Access agent 負責把實際 GET request 的 tenant/file/method 邊界帶回 Member Center 驗證
|
||||
- `aud` 預設:
|
||||
- Send Engine 流程:`send_engine_api`(可用 `Auth:SendEngineAudience` 覆寫)
|
||||
- Member Center API 流程:`member_center_api`(可用 `Auth:MemberCenterAudience` 覆寫)
|
||||
|
||||
### 回寫原因碼(Send Engine -> Member Center)
|
||||
- `hard_bounce`
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
# Member Center Test Site
|
||||
|
||||
`src/MemberCenter.TestSite` 是手動 happy-path 測試用網站,用來先驗證外部網站整合會員中心的最小流程。
|
||||
|
||||
## 啟動
|
||||
|
||||
```bash
|
||||
dotnet run --project src/MemberCenter.TestSite
|
||||
```
|
||||
|
||||
預設 HTTP URL:
|
||||
|
||||
```text
|
||||
http://localhost:5243
|
||||
```
|
||||
|
||||
## 必要設定
|
||||
|
||||
在 `src/MemberCenter.TestSite/appsettings.Development.json` 或環境變數設定:
|
||||
|
||||
```json
|
||||
{
|
||||
"MemberCenter": {
|
||||
"ApiBaseUrl": "http://localhost:7850",
|
||||
"WebBaseUrl": "http://localhost:5080",
|
||||
"WebLoginClientId": "<web_login client id>",
|
||||
"ServiceClientId": "<service client id>",
|
||||
"ServiceClientSecret": "<service client secret>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`web_login` OAuth client:
|
||||
- `usage=web_login`
|
||||
- `client_type=public`
|
||||
- redirect URI: `http://localhost:5243/auth/callback`
|
||||
- scopes: `openid email profile profile:basic.read profile:basic.write profile:addresses.read profile:addresses.write profile:subscriptions.read profile:subscriptions.write`
|
||||
|
||||
service OAuth client:
|
||||
- 建議使用 `tenant_api` 或 `platform_service`
|
||||
- `client_type=confidential`
|
||||
- scopes 至少包含 `profile:basic.read profile:addresses.read`
|
||||
|
||||
若 Member Center API 與 Web login 不同 origin,Member Center 需設定:
|
||||
|
||||
```text
|
||||
Auth__WebLoginUrl=<Member Center Web login URL>
|
||||
Auth__AllowedLoginReturnUrlPrefixes=<Member Center API issuer/base URL>
|
||||
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
|
||||
Auth__CookieDomain=<shared cookie domain, production subdomain SSO only>
|
||||
```
|
||||
|
||||
## 第一批 Happy Path
|
||||
|
||||
測試站目前包含:
|
||||
|
||||
1. Redirect login 拿 token
|
||||
2. Redirect logout 清除 Member Center Web session 並回到 TestSite
|
||||
3. API login 拿 token
|
||||
4. `GET /user/profile`
|
||||
5. `POST /user/profile`
|
||||
6. `GET /user/addresses`
|
||||
7. `POST /user/addresses`
|
||||
8. `GET /user/subscriptions`
|
||||
9. `POST /user/subscriptions/{id}/unsubscribe`
|
||||
10. service token 呼叫 `GET /user/profile/by-email`
|
||||
11. service token 呼叫 `GET /user/addresses/by-email`
|
||||
|
||||
測試站只做 happy path,不取代完整自動化測試。
|
||||
34
docs/UI.md
34
docs/UI.md
@ -2,11 +2,10 @@
|
||||
|
||||
## 會員中心(統一 UI)
|
||||
### 會員端
|
||||
- 註冊 / 登入 / 忘記密碼 / 修改密碼
|
||||
- 註冊 / 登入 / 忘記密碼
|
||||
- Email 驗證
|
||||
- 個人資料(基本資料、聯絡方式、公司資訊)
|
||||
- 收貨地址簿
|
||||
- 訂閱管理(清單與退訂)
|
||||
- 個人資料(Email 為主)
|
||||
- 訂閱管理(清單與偏好)
|
||||
- 退訂(單一清單)
|
||||
- 連結外站(可選:回到來源站點)
|
||||
|
||||
@ -17,14 +16,13 @@
|
||||
- 訂閱查詢 / 匯出
|
||||
- 審計紀錄查詢
|
||||
- 系統設定(安全策略、token 時效)
|
||||
- Auth 資源設定(resource / audience / scope / usage mapping)
|
||||
|
||||
## 各站自建 UI(API)
|
||||
### 會員端
|
||||
- 登入 / 註冊 / 忘記密碼 / 修改密碼
|
||||
- 登入 / 註冊 / 忘記密碼
|
||||
- Email 驗證頁(可自建或導回會員中心)
|
||||
- 訂閱表單(未登入)
|
||||
- 外站自建訂閱偏好管理(登入後,走 API)
|
||||
- 訂閱偏好管理(登入後)
|
||||
- 退訂頁(從 email token 進來)
|
||||
|
||||
### 管理者端
|
||||
@ -35,36 +33,26 @@
|
||||
- 會員中心 UI 為統一入口(少數情境)
|
||||
- 其餘皆走 API 與各站自建 UI
|
||||
- 會員中心 UI 不承擔行銷內容或寄送
|
||||
- `MemberCenter.Web` 採 client-first 介面;admin 功能以角色判斷後顯示於同一登入入口內
|
||||
- `/admin/*` 由 `Areas/Admin` 提供獨立管理區殼層
|
||||
- 非 admin 或未登入存取 `/admin/*` 時,回 `404`
|
||||
## UI 路徑對應(Use Cases)
|
||||
|
||||
### 會員端(統一 UI)
|
||||
- UC-01 註冊會員: `/account/register`
|
||||
- UC-02 登入: `/account/login`
|
||||
- UC-03 登出: `POST /account/logout`(外站 direct logout 可導向 `GET /account/logout?returnUrl=...`)
|
||||
- UC-03 登出: `POST /account/logout`
|
||||
- UC-04 忘記密碼 / 重設密碼: `/account/forgotpassword`, `/account/resetpassword`
|
||||
- UC-04.1 已登入修改密碼: `/account/changepassword`
|
||||
- UC-05 Email 驗證: `/account/verifyemail?email=...&token=...`
|
||||
- UC-07 訂閱確認(double opt-in): `/newsletter/confirm?token=...`
|
||||
- UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...`
|
||||
- UC-09 訂閱偏好管理(外站整合 API): `/newsletter/preferences?list_id=...&email=...`
|
||||
- UC-10 會員資料查看 / 編輯: `/profile`
|
||||
- UC-10.1 收貨地址簿管理: `/profile/addresses`
|
||||
- UC-10.2 我的電子報訂閱: `/profile/subscriptions`
|
||||
- UC-09 訂閱偏好管理(登入後): `/newsletter/preferences?list_id=...&email=...`
|
||||
- UC-10 會員資料查看: `/profile`
|
||||
|
||||
### 管理者端(統一 UI)
|
||||
- UC-11 租戶管理: `/admin/tenants`
|
||||
- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`(UUID)
|
||||
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;可選 `usage=tenant_api` / `send_api` / `web_login` / `webhook_outbound` / `platform_service` / `file_api`;`platform_service` / `web_login` 可不指定 tenant)
|
||||
- `redirect_uris` 僅 `web_login` / `webhook_outbound` 需要;其餘 usage 不需要
|
||||
- `tenant_api` / `send_api` / `platform_service` / `file_api` 強制 `client_type=confidential`
|
||||
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;可選 `usage=tenant_api` / `send_api` / `webhook_outbound` / `platform_service`;`platform_service` 可不指定 tenant)
|
||||
- `redirect_uris` 僅 `webhook_outbound` 需要;其餘 usage 不需要
|
||||
- `tenant_api` / `send_api` / `platform_service` 強制 `client_type=confidential`
|
||||
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
||||
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
|
||||
- UC-15 審計紀錄查詢: `/admin/audit-logs`
|
||||
- UC-16 安全策略設定: `/admin/security`
|
||||
- UC-16.1 Auth 資源設定:建議整併於 `/admin/security` 或其子頁籤,而非獨立新頁
|
||||
- 管理 `Issuer` 顯示資訊與非敏感 Auth 設定
|
||||
- 管理 resource registry,例如 `member_center_api`、`send_engine_api`、`file_access_api`
|
||||
- audience 設定建議放在現有 Auth / Security 設定畫面下,而不是放到 SMTP / Send Engine 整合設定頁
|
||||
|
||||
@ -9,12 +9,11 @@
|
||||
- UC-02 登入(取得 token) [API/UI]
|
||||
- UC-03 登出 [API/UI]
|
||||
- UC-04 忘記密碼 / 重設密碼 [API/UI]
|
||||
- UC-04.1 已登入修改密碼 [UI]
|
||||
- UC-05 Email 驗證 [API/UI]
|
||||
- UC-06 訂閱電子報(未登入) [API]
|
||||
- UC-07 訂閱確認(double opt-in) [UI]
|
||||
- UC-08 取消訂閱(單一清單) [UI]
|
||||
- UC-09 訂閱偏好管理(登入後) [API]
|
||||
- UC-09 訂閱偏好管理(登入後) [API/UI]
|
||||
- UC-10 會員資料查看(Email 為主) [API/UI]
|
||||
|
||||
## 管理者端
|
||||
@ -32,4 +31,4 @@
|
||||
|
||||
## 實作狀態(2026-02)
|
||||
- 已完成:UC-17、UC-18(以 webhook 事件發送)
|
||||
- 已完成:UC-19(註冊後自動綁定 `user_id` 與 `subscription.linked_to_user` 事件)
|
||||
- 未完成:UC-19(註冊後自動綁定 `user_id` 與 `subscription.linked_to_user` 事件)
|
||||
|
||||
@ -138,7 +138,7 @@ paths:
|
||||
|
||||
/auth/logout:
|
||||
post:
|
||||
summary: Logout current authenticated session
|
||||
summary: Logout (revoke refresh token)
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
@ -810,48 +810,6 @@ paths:
|
||||
'204':
|
||||
description: Deleted
|
||||
|
||||
/file-access/download-tokens:
|
||||
post:
|
||||
summary: Issue delegated short-lived file download token
|
||||
description: |
|
||||
Called by a service after it has verified the Member Center user is allowed to download the requested file.
|
||||
Requires `files:download.delegate`.
|
||||
security: [{ BearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/IssueFileDownloadTokenRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Token issued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FileDownloadTokenResponse'
|
||||
|
||||
/file-access/download-tokens/validate:
|
||||
post:
|
||||
summary: Validate delegated file download token
|
||||
description: |
|
||||
Called by File Access agent with the token from the client GET request and the actual request boundary.
|
||||
Requires `files:download.read`.
|
||||
security: [{ BearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidateFileDownloadTokenRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Validation result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FileDownloadTokenValidationResponse'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
OAuth2:
|
||||
@ -870,11 +828,6 @@ components:
|
||||
newsletter:events.read: Read newsletter events
|
||||
newsletter:events.write: Write newsletter events (tenant scoped)
|
||||
newsletter:events.write.global: Write newsletter events (platform scoped)
|
||||
files:upload.write: Upload files to file space via access agent
|
||||
files:download.read: Read file content via delegated short-lived token
|
||||
files:download.delegate: Delegate short-lived file download authorization
|
||||
files:delete: Delete files from file space
|
||||
files:metadata.read: Read file metadata
|
||||
clientCredentials:
|
||||
tokenUrl: /oauth/token
|
||||
scopes:
|
||||
@ -884,11 +837,6 @@ components:
|
||||
newsletter:events.read: Read newsletter events
|
||||
newsletter:events.write: Write newsletter events (tenant scoped)
|
||||
newsletter:events.write.global: Write newsletter events (platform scoped)
|
||||
files:upload.write: Upload files to file space via access agent
|
||||
files:download.read: Read file content via delegated short-lived token
|
||||
files:download.delegate: Delegate short-lived file download authorization
|
||||
files:delete: Delete files from file space
|
||||
files:metadata.read: Read file metadata
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
@ -969,52 +917,6 @@ components:
|
||||
token: { type: string }
|
||||
new_password: { type: string }
|
||||
|
||||
IssueFileDownloadTokenRequest:
|
||||
type: object
|
||||
required: [tenant_id, user_id]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
file_id: { type: string, nullable: true }
|
||||
object_key: { type: string, nullable: true }
|
||||
method: { type: string, enum: [GET], default: GET }
|
||||
expires_in_seconds: { type: integer, minimum: 30, maximum: 900, default: 300 }
|
||||
|
||||
FileDownloadTokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
token: { type: string }
|
||||
token_type: { type: string, example: file_download }
|
||||
expires_at: { type: string, format: date-time }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
file_id: { type: string, nullable: true }
|
||||
object_key: { type: string, nullable: true }
|
||||
method: { type: string, example: GET }
|
||||
scope: { type: string, example: files:download.read }
|
||||
|
||||
ValidateFileDownloadTokenRequest:
|
||||
type: object
|
||||
required: [token, tenant_id]
|
||||
properties:
|
||||
token: { type: string }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
file_id: { type: string, nullable: true }
|
||||
object_key: { type: string, nullable: true }
|
||||
method: { type: string, enum: [GET], default: GET }
|
||||
|
||||
FileDownloadTokenValidationResponse:
|
||||
type: object
|
||||
properties:
|
||||
active: { type: boolean }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
file_id: { type: string, nullable: true }
|
||||
object_key: { type: string, nullable: true }
|
||||
method: { type: string, example: GET }
|
||||
scope: { type: string, example: files:download.read }
|
||||
expires_at: { type: string, format: date-time }
|
||||
|
||||
UserProfile:
|
||||
type: object
|
||||
properties:
|
||||
@ -1100,7 +1002,7 @@ components:
|
||||
required: [event_id, event_type, tenant_id, list_id, subscriber, occurred_at]
|
||||
properties:
|
||||
event_id: { type: string }
|
||||
event_type: { type: string, enum: [subscription.activated, subscription.unsubscribed, preferences.updated, subscription.linked_to_user] }
|
||||
event_type: { type: string, enum: [subscription.activated, subscription.unsubscribed, preferences.updated] }
|
||||
tenant_id: { type: string }
|
||||
list_id: { type: string }
|
||||
subscriber:
|
||||
@ -1169,6 +1071,6 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
name: { type: string }
|
||||
usage: { type: string, enum: [tenant_api, send_api, web_login, webhook_outbound, platform_service, file_api] }
|
||||
usage: { type: string, enum: [tenant_api, send_api, webhook_outbound, platform_service] }
|
||||
redirect_uris: { type: array, items: { type: string } }
|
||||
client_type: { type: string, enum: [public, confidential] }
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MemberCenter.Api.Contracts;
|
||||
|
||||
public sealed record IssueFileDownloadTokenRequest(
|
||||
[property: JsonPropertyName("tenant_id")] Guid TenantId,
|
||||
[property: JsonPropertyName("user_id")] Guid UserId,
|
||||
[property: JsonPropertyName("file_id")] string? FileId,
|
||||
[property: JsonPropertyName("object_key")] string? ObjectKey,
|
||||
[property: JsonPropertyName("method")] string? Method,
|
||||
[property: JsonPropertyName("expires_in_seconds")] int? ExpiresInSeconds);
|
||||
|
||||
public sealed record ValidateFileDownloadTokenRequest(
|
||||
[property: JsonPropertyName("token")] string Token,
|
||||
[property: JsonPropertyName("tenant_id")] Guid TenantId,
|
||||
[property: JsonPropertyName("file_id")] string? FileId,
|
||||
[property: JsonPropertyName("object_key")] string? ObjectKey,
|
||||
[property: JsonPropertyName("method")] string? Method);
|
||||
@ -1,34 +0,0 @@
|
||||
namespace MemberCenter.Api.Contracts;
|
||||
|
||||
public sealed record SaveProfileRequest(
|
||||
string LastName,
|
||||
string FirstName,
|
||||
string? NickName,
|
||||
string? MobilePhone,
|
||||
string? LandlinePhone,
|
||||
DateOnly? DateOfBirth,
|
||||
string Gender,
|
||||
string? CompanyName,
|
||||
string? Department,
|
||||
string? JobTitle,
|
||||
string? CompanyPhone,
|
||||
string? TaxId,
|
||||
string? InvoiceTitle,
|
||||
string? Remark);
|
||||
|
||||
public sealed record SaveAddressRequest(
|
||||
Guid? Id,
|
||||
string Label,
|
||||
string RecipientName,
|
||||
string RecipientPhone,
|
||||
string CountryCode,
|
||||
string? PostalCode,
|
||||
string? StateRegion,
|
||||
string? City,
|
||||
string? District,
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string? CompanyName,
|
||||
string Usage,
|
||||
bool IsDefault,
|
||||
string? AddressMetaJson);
|
||||
@ -1,5 +1,4 @@
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OpenIddict.Abstractions;
|
||||
@ -13,14 +12,10 @@ namespace MemberCenter.Api.Controllers;
|
||||
public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||
private readonly IAuthResourceRegistryService _authResourceRegistry;
|
||||
|
||||
public AdminOAuthClientsController(
|
||||
IOpenIddictApplicationManager applicationManager,
|
||||
IAuthResourceRegistryService authResourceRegistry)
|
||||
public AdminOAuthClientsController(IOpenIddictApplicationManager applicationManager)
|
||||
{
|
||||
_applicationManager = applicationManager;
|
||||
_authResourceRegistry = authResourceRegistry;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@ -48,7 +43,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
if (!IsValidUsage(request.Usage))
|
||||
{
|
||||
return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api.");
|
||||
return BadRequest("usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
|
||||
}
|
||||
|
||||
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
||||
@ -69,7 +64,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
}
|
||||
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
|
||||
{
|
||||
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
|
||||
return BadRequest("redirect_uris is required for webhook_outbound usage.");
|
||||
}
|
||||
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
@ -78,7 +73,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
DisplayName = request.Name,
|
||||
ClientType = request.ClientType
|
||||
};
|
||||
await ApplyPermissionsAsync(descriptor, request.Usage);
|
||||
ApplyPermissions(descriptor, request.Usage);
|
||||
|
||||
foreach (var uri in redirectUris)
|
||||
{
|
||||
@ -127,7 +122,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
if (!IsValidUsage(request.Usage))
|
||||
{
|
||||
return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api.");
|
||||
return BadRequest("usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
|
||||
}
|
||||
|
||||
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
||||
@ -148,7 +143,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
}
|
||||
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
|
||||
{
|
||||
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
|
||||
return BadRequest("redirect_uris is required for webhook_outbound usage.");
|
||||
}
|
||||
|
||||
var app = await _applicationManager.FindByIdAsync(id);
|
||||
@ -162,12 +157,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
|
||||
descriptor.DisplayName = request.Name;
|
||||
descriptor.ClientType = request.ClientType;
|
||||
if (string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descriptor.ClientSecret = null;
|
||||
}
|
||||
|
||||
await ApplyPermissionsAsync(descriptor, request.Usage);
|
||||
ApplyPermissions(descriptor, request.Usage);
|
||||
descriptor.RedirectUris.Clear();
|
||||
foreach (var uri in redirectUris)
|
||||
{
|
||||
@ -211,30 +201,25 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "file_api", StringComparison.OrdinalIgnoreCase);
|
||||
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsTenantOptionalUsage(string usage)
|
||||
{
|
||||
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase);
|
||||
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool RequiresClientCredentials(string usage)
|
||||
{
|
||||
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "file_api", StringComparison.OrdinalIgnoreCase);
|
||||
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool UsesAuthorizationCodeFlow(string usage)
|
||||
{
|
||||
return string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
||||
return string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static (List<string> Uris, string? Error) NormalizeRedirectUris(List<string>? redirectUris)
|
||||
@ -261,27 +246,38 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
return (values, null);
|
||||
}
|
||||
|
||||
private async Task ApplyPermissionsAsync(OpenIddictApplicationDescriptor descriptor, string usage)
|
||||
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
|
||||
{
|
||||
descriptor.Permissions.Clear();
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
||||
|
||||
if (UsesAuthorizationCodeFlow(usage))
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
|
||||
}
|
||||
else
|
||||
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
||||
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descriptor.Permissions.Add("scp:newsletter:events.write.global");
|
||||
}
|
||||
else if (string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descriptor.Permissions.Add("scp:newsletter:send.write");
|
||||
descriptor.Permissions.Add("scp:newsletter:send.read");
|
||||
}
|
||||
else
|
||||
{
|
||||
descriptor.Permissions.Add("scp:newsletter:events.write");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope);
|
||||
}
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile);
|
||||
descriptor.Permissions.Add("scp:openid");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Constants;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MemberCenter.Api.Controllers;
|
||||
|
||||
@ -13,53 +10,42 @@ namespace MemberCenter.Api.Controllers;
|
||||
[Route("auth")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAccountProvisioningService _accountProvisioningService;
|
||||
private readonly IAccountEmailService _accountEmailService;
|
||||
private readonly IAuditLogWriter _auditLogWriter;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
|
||||
public AuthController(
|
||||
IAccountProvisioningService accountProvisioningService,
|
||||
IAccountEmailService accountEmailService,
|
||||
IAuditLogWriter auditLogWriter,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager)
|
||||
public AuthController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
|
||||
{
|
||||
_accountProvisioningService = accountProvisioningService;
|
||||
_accountEmailService = accountEmailService;
|
||||
_auditLogWriter = auditLogWriter;
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||
{
|
||||
var result = await _accountProvisioningService.RegisterLocalAsync(request.Email, request.Password);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = request.Email,
|
||||
Email = request.Email,
|
||||
EmailConfirmed = false
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, request.Password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return BadRequest(result.Errors);
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByEmailAsync(request.Email);
|
||||
if (user is not null)
|
||||
{
|
||||
await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl());
|
||||
return BadRequest(result.Errors.Select(e => e.Description));
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
id = result.UserId,
|
||||
email = result.Email,
|
||||
email_verified = result.EmailConfirmed,
|
||||
linked_subscriptions = result.LinkedSubscriptionsCount
|
||||
id = user.Id,
|
||||
email = user.Email,
|
||||
email_verified = user.EmailConfirmed,
|
||||
created_at = user.CreatedAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("password/forgot")]
|
||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
|
||||
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(request.Email);
|
||||
@ -68,8 +54,8 @@ public class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
await _accountEmailService.SendPasswordResetEmailAsync(user.Id, GetBaseUrl());
|
||||
return NoContent();
|
||||
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||
return Ok(new { token });
|
||||
}
|
||||
|
||||
[HttpPost("password/reset")]
|
||||
@ -87,11 +73,6 @@ public class AuthController : ControllerBase
|
||||
return BadRequest(result.Errors.Select(e => e.Description));
|
||||
}
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", user.Id, "account.password_reset_completed", new
|
||||
{
|
||||
user_id = user.Id,
|
||||
email = user.Email
|
||||
});
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -110,33 +91,9 @@ public class AuthController : ControllerBase
|
||||
return BadRequest(result.Errors.Select(e => e.Description));
|
||||
}
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", user.Id, "account.email_verified", new
|
||||
{
|
||||
user_id = user.Id,
|
||||
email = user.Email
|
||||
});
|
||||
return Ok(new { status = "verified" });
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("email/resend")]
|
||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
|
||||
public async Task<IActionResult> ResendVerification()
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!user.EmailConfirmed)
|
||||
{
|
||||
await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl());
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout([FromBody] LogoutRequest request)
|
||||
@ -144,6 +101,4 @@ public class AuthController : ControllerBase
|
||||
await _signInManager.SignOutAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
||||
}
|
||||
|
||||
@ -1,204 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace MemberCenter.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("file-access/download-tokens")]
|
||||
public sealed class FileAccessController : ControllerBase
|
||||
{
|
||||
private const string DownloadScope = "files:download.read";
|
||||
private const string TokenPurpose = "file_access_download";
|
||||
private static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan MinimumLifetime = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public FileAccessController(MemberCenterDbContext dbContext, UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
[Authorize(Policy = "FilesDownloadDelegate")]
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> Issue([FromBody] IssueFileDownloadTokenRequest request)
|
||||
{
|
||||
var validationError = ValidateBoundary(request.TenantId, request.FileId, request.ObjectKey, request.Method);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return BadRequest(validationError);
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(request.TenantId))
|
||||
{
|
||||
return BadRequest("tenant_id does not match token tenant scope.");
|
||||
}
|
||||
|
||||
if (request.UserId == Guid.Empty)
|
||||
{
|
||||
return BadRequest("user_id is required.");
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByIdAsync(request.UserId.ToString());
|
||||
if (user is null || user.DisabledAt.HasValue)
|
||||
{
|
||||
return BadRequest("user_id is invalid or disabled.");
|
||||
}
|
||||
|
||||
var lifetime = ResolveLifetime(request.ExpiresInSeconds);
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(lifetime);
|
||||
var token = $"fdt_{WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32))}";
|
||||
|
||||
var record = new FileAccessDownloadToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TokenHash = HashToken(token),
|
||||
TenantId = request.TenantId,
|
||||
UserId = request.UserId,
|
||||
FileId = NormalizeOptional(request.FileId),
|
||||
ObjectKey = NormalizeOptional(request.ObjectKey),
|
||||
Method = "GET",
|
||||
Scope = DownloadScope,
|
||||
IssuedByClientId = User.GetClaim(OpenIddictConstants.Claims.ClientId),
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
_dbContext.FileAccessDownloadTokens.Add(record);
|
||||
await _dbContext.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
token,
|
||||
token_type = "file_download",
|
||||
expires_at = expiresAt,
|
||||
tenant_id = record.TenantId,
|
||||
user_id = record.UserId,
|
||||
file_id = record.FileId,
|
||||
object_key = record.ObjectKey,
|
||||
method = record.Method,
|
||||
scope = record.Scope
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize(Policy = "FilesDownloadRead")]
|
||||
[HttpPost("validate")]
|
||||
public async Task<IActionResult> Validate([FromBody] ValidateFileDownloadTokenRequest request)
|
||||
{
|
||||
var validationError = ValidateBoundary(request.TenantId, request.FileId, request.ObjectKey, request.Method);
|
||||
if (validationError is not null || string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
return Ok(new { active = false });
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(request.TenantId))
|
||||
{
|
||||
return Ok(new { active = false });
|
||||
}
|
||||
|
||||
var tokenHash = HashToken(request.Token);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = await _dbContext.FileAccessDownloadTokens
|
||||
.SingleOrDefaultAsync(token =>
|
||||
token.TokenHash == tokenHash
|
||||
&& token.RevokedAt == null
|
||||
&& token.ExpiresAt > now,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
if (record is null
|
||||
|| record.TenantId != request.TenantId
|
||||
|| !string.Equals(record.Method, "GET", StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.Equals(record.FileId, NormalizeOptional(request.FileId), StringComparison.Ordinal)
|
||||
|| !string.Equals(record.ObjectKey, NormalizeOptional(request.ObjectKey), StringComparison.Ordinal))
|
||||
{
|
||||
return Ok(new { active = false });
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByIdAsync(record.UserId.ToString());
|
||||
if (user is null || user.DisabledAt.HasValue)
|
||||
{
|
||||
return Ok(new { active = false });
|
||||
}
|
||||
|
||||
record.LastValidatedAt = now;
|
||||
await _dbContext.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
active = true,
|
||||
tenant_id = record.TenantId,
|
||||
user_id = record.UserId,
|
||||
file_id = record.FileId,
|
||||
object_key = record.ObjectKey,
|
||||
method = record.Method,
|
||||
scope = record.Scope,
|
||||
expires_at = record.ExpiresAt
|
||||
});
|
||||
}
|
||||
|
||||
private bool IsTenantAllowed(Guid tenantId)
|
||||
{
|
||||
var tokenTenantId = User.FindFirst("tenant_id")?.Value;
|
||||
return string.IsNullOrWhiteSpace(tokenTenantId)
|
||||
|| (Guid.TryParse(tokenTenantId, out var parsed) && parsed == tenantId);
|
||||
}
|
||||
|
||||
private static string? ValidateBoundary(Guid tenantId, string? fileId, string? objectKey, string? method)
|
||||
{
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
return "tenant_id is required.";
|
||||
}
|
||||
|
||||
if (!string.Equals(method ?? "GET", "GET", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Only method=GET is supported for file download tokens.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fileId) && string.IsNullOrWhiteSpace(objectKey))
|
||||
{
|
||||
return "file_id or object_key is required.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TimeSpan ResolveLifetime(int? expiresInSeconds)
|
||||
{
|
||||
if (!expiresInSeconds.HasValue)
|
||||
{
|
||||
return DefaultLifetime;
|
||||
}
|
||||
|
||||
var requested = TimeSpan.FromSeconds(expiresInSeconds.Value);
|
||||
if (requested < MinimumLifetime)
|
||||
{
|
||||
return MinimumLifetime;
|
||||
}
|
||||
|
||||
return requested > MaximumLifetime ? MaximumLifetime : requested;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string HashToken(string token)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{TokenPurpose}:{token}"));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,7 @@
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace MemberCenter.Api.Controllers;
|
||||
@ -20,7 +18,6 @@ public class NewsletterController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("subscribe")]
|
||||
[EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterSubscribe)]
|
||||
public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request)
|
||||
{
|
||||
var result = await _newsletterService.SubscribeAsync(request.ListId, request.Email, request.Preferences);
|
||||
@ -79,7 +76,6 @@ public class NewsletterController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("unsubscribe-token")]
|
||||
[EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterUnsubscribeToken)]
|
||||
public async Task<IActionResult> IssueUnsubscribeToken([FromBody] IssueUnsubscribeTokenRequest request)
|
||||
{
|
||||
if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email))
|
||||
|
||||
@ -1,35 +1,28 @@
|
||||
using MemberCenter.Api.Extensions;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using System.Web;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace MemberCenter.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
public class OAuthController : ControllerBase
|
||||
{
|
||||
private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp";
|
||||
private readonly string _memberCenterAudience;
|
||||
private readonly string _webLoginUrl;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
|
||||
public OAuthController(
|
||||
IConfiguration configuration,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager)
|
||||
public OAuthController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
|
||||
{
|
||||
_memberCenterAudience = configuration["Auth:MemberCenterAudience"] ?? "member_center_api";
|
||||
_webLoginUrl = configuration["Auth:WebLoginUrl"] ?? "/account/login";
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
}
|
||||
|
||||
[HttpGet("/oauth/authorize")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Authorize()
|
||||
{
|
||||
var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.Transaction?.Request;
|
||||
@ -38,31 +31,14 @@ public class OAuthController : ControllerBase
|
||||
return BadRequest("Invalid OpenIddict request.");
|
||||
}
|
||||
|
||||
var cookie = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
|
||||
if (!cookie.Succeeded || cookie.Principal is null)
|
||||
{
|
||||
return Redirect(BuildLoginRedirectUrl());
|
||||
}
|
||||
|
||||
var user = await _userManager.GetUserAsync(cookie.Principal);
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
if (user.DisabledAt.HasValue)
|
||||
{
|
||||
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
var principal = await _signInManager.CreateUserPrincipalAsync(user);
|
||||
principal.SetClaim(OpenIddictConstants.Claims.Subject, user.Id.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(user.SecurityStamp))
|
||||
{
|
||||
principal.SetClaim(SecurityStampClaimType, user.SecurityStamp);
|
||||
}
|
||||
principal.SetScopes(request.GetScopes());
|
||||
principal.SetResources(_memberCenterAudience);
|
||||
foreach (var claim in principal.Claims)
|
||||
{
|
||||
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
|
||||
@ -70,11 +46,4 @@ public class OAuthController : ControllerBase
|
||||
|
||||
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
private string BuildLoginRedirectUrl()
|
||||
{
|
||||
var returnUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}{Request.QueryString}";
|
||||
var separator = _webLoginUrl.Contains('?', StringComparison.Ordinal) ? '&' : '?';
|
||||
return $"{_webLoginUrl}{separator}returnUrl={HttpUtility.UrlEncode(returnUrl)}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
using MemberCenter.Api.Extensions;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -14,22 +13,23 @@ namespace MemberCenter.Api.Controllers;
|
||||
[ApiController]
|
||||
public class TokenController : ControllerBase
|
||||
{
|
||||
private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp";
|
||||
private readonly string _memberCenterAudience;
|
||||
private readonly string _sendEngineAudience;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||
private readonly IAuthResourceRegistryService _authResourceRegistry;
|
||||
|
||||
public TokenController(
|
||||
IConfiguration configuration,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
IOpenIddictApplicationManager applicationManager,
|
||||
IAuthResourceRegistryService authResourceRegistry)
|
||||
IOpenIddictApplicationManager applicationManager)
|
||||
{
|
||||
_memberCenterAudience = configuration["Auth:MemberCenterAudience"] ?? "member_center_api";
|
||||
_sendEngineAudience = configuration["Auth:SendEngineAudience"] ?? "send_engine_api";
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
_applicationManager = applicationManager;
|
||||
_authResourceRegistry = authResourceRegistry;
|
||||
}
|
||||
|
||||
[HttpPost("/oauth/token")]
|
||||
@ -46,40 +46,31 @@ public class TokenController : ControllerBase
|
||||
if (request.IsPasswordGrantType())
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(request.Username ?? string.Empty);
|
||||
if (user is null || user.DisabledAt.HasValue)
|
||||
if (user is null)
|
||||
{
|
||||
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
var signInResult = await _signInManager.CheckPasswordSignInAsync(user, request.Password ?? string.Empty, true);
|
||||
if (!signInResult.Succeeded)
|
||||
var valid = await _userManager.CheckPasswordAsync(user, request.Password ?? string.Empty);
|
||||
if (!valid)
|
||||
{
|
||||
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
var principal = await _signInManager.CreateUserPrincipalAsync(user);
|
||||
principal.SetClaim(OpenIddictConstants.Claims.Subject, user.Id.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(user.SecurityStamp))
|
||||
{
|
||||
principal.SetClaim(SecurityStampClaimType, user.SecurityStamp);
|
||||
}
|
||||
var scopes = request.Scope.GetScopesOrDefault();
|
||||
principal.SetScopes(scopes);
|
||||
principal.SetResources(await ResolveResourcesAsync(scopes));
|
||||
principal.SetResources(ResolveResources(scopes));
|
||||
|
||||
foreach (var claim in principal.Claims)
|
||||
{
|
||||
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
|
||||
}
|
||||
|
||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
user.LastSeenAt = user.LastLoginAt;
|
||||
await _userManager.UpdateAsync(user);
|
||||
|
||||
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
|
||||
if (request.IsRefreshTokenGrantType())
|
||||
{
|
||||
var authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
if (!authenticateResult.Succeeded || authenticateResult.Principal is null)
|
||||
@ -88,11 +79,6 @@ public class TokenController : ControllerBase
|
||||
}
|
||||
|
||||
var principal = authenticateResult.Principal;
|
||||
if (!await ValidateUserPrincipalAsync(principal))
|
||||
{
|
||||
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
@ -143,7 +129,7 @@ public class TokenController : ControllerBase
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var scopes = request.Scope.GetScopesOrDefault();
|
||||
principal.SetScopes(scopes);
|
||||
principal.SetResources(await ResolveResourcesAsync(scopes));
|
||||
principal.SetResources(ResolveResources(scopes));
|
||||
|
||||
foreach (var claim in principal.Claims)
|
||||
{
|
||||
@ -156,28 +142,34 @@ public class TokenController : ControllerBase
|
||||
return BadRequest("Unsupported grant type.");
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateUserPrincipalAsync(ClaimsPrincipal principal)
|
||||
private IEnumerable<string> ResolveResources(IEnumerable<string> scopes)
|
||||
{
|
||||
var subject = principal.GetClaim(OpenIddictConstants.Claims.Subject)
|
||||
?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!Guid.TryParse(subject, out var userId))
|
||||
var scopeSet = scopes as ISet<string> ?? new HashSet<string>(scopes, StringComparer.Ordinal);
|
||||
if (scopeSet.Count == 0)
|
||||
{
|
||||
return true;
|
||||
return [_memberCenterAudience];
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null || user.DisabledAt.HasValue)
|
||||
var resources = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (scopeSet.Contains("newsletter:send.write") || scopeSet.Contains("newsletter:send.read"))
|
||||
{
|
||||
return false;
|
||||
resources.Add(_sendEngineAudience);
|
||||
}
|
||||
|
||||
var tokenSecurityStamp = principal.FindFirst(SecurityStampClaimType)?.Value;
|
||||
return string.IsNullOrWhiteSpace(tokenSecurityStamp)
|
||||
|| string.Equals(tokenSecurityStamp, user.SecurityStamp, StringComparison.Ordinal);
|
||||
}
|
||||
if (scopeSet.Any(scope => scope.StartsWith("newsletter:", StringComparison.Ordinal) && scope is not "newsletter:send.write" && scope is not "newsletter:send.read")
|
||||
|| scopeSet.Contains(OpenIddictConstants.Scopes.OpenId)
|
||||
|| scopeSet.Contains(OpenIddictConstants.Scopes.Email)
|
||||
|| scopeSet.Contains(OpenIddictConstants.Scopes.Profile))
|
||||
{
|
||||
resources.Add(_memberCenterAudience);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> ResolveResourcesAsync(IEnumerable<string> scopes)
|
||||
{
|
||||
return await _authResourceRegistry.ResolveAudiencesAsync(scopes, HttpContext.RequestAborted);
|
||||
if (resources.Count == 0)
|
||||
{
|
||||
resources.Add(_memberCenterAudience);
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,3 @@
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Api.Extensions;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Models.Profile;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -13,21 +9,14 @@ namespace MemberCenter.Api.Controllers;
|
||||
[Route("user")]
|
||||
public class UserController : ControllerBase
|
||||
{
|
||||
private readonly IProfileService _profileService;
|
||||
private readonly INewsletterService _newsletterService;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public UserController(
|
||||
IProfileService profileService,
|
||||
INewsletterService newsletterService,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
public UserController(UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_profileService = profileService;
|
||||
_newsletterService = newsletterService;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileBasicRead")]
|
||||
[Authorize]
|
||||
[HttpGet("profile")]
|
||||
public async Task<IActionResult> Profile()
|
||||
{
|
||||
@ -37,280 +26,12 @@ public class UserController : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var profile = await _profileService.GetProfileAsync(user.Id);
|
||||
return Ok(new
|
||||
{
|
||||
id = user.Id,
|
||||
email = user.Email,
|
||||
email_verified = user.EmailConfirmed,
|
||||
created_at = user.CreatedAt,
|
||||
profile
|
||||
created_at = user.CreatedAt
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileBasicWrite")]
|
||||
[HttpPost("profile")]
|
||||
public async Task<IActionResult> SaveProfile([FromBody] SaveProfileRequest request)
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var profile = await _profileService.SaveProfileAsync(user.Id, new SaveUserProfileRequest(
|
||||
request.LastName,
|
||||
request.FirstName,
|
||||
request.NickName,
|
||||
request.MobilePhone,
|
||||
request.LandlinePhone,
|
||||
request.DateOfBirth,
|
||||
request.Gender,
|
||||
request.CompanyName,
|
||||
request.Department,
|
||||
request.JobTitle,
|
||||
request.CompanyPhone,
|
||||
request.TaxId,
|
||||
request.InvoiceTitle,
|
||||
request.Remark));
|
||||
return Ok(profile);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileAddressesRead")]
|
||||
[HttpGet("addresses")]
|
||||
public async Task<IActionResult> Addresses()
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(await _profileService.ListAddressesAsync(user.Id));
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileAddressesWrite")]
|
||||
[HttpPost("addresses")]
|
||||
public async Task<IActionResult> SaveAddress([FromBody] SaveAddressRequest request)
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var address = await _profileService.SaveAddressAsync(user.Id, new SaveUserAddressRequest(
|
||||
request.Id,
|
||||
request.Label,
|
||||
request.RecipientName,
|
||||
request.RecipientPhone,
|
||||
request.CountryCode,
|
||||
request.PostalCode,
|
||||
request.StateRegion,
|
||||
request.City,
|
||||
request.District,
|
||||
request.AddressLine1,
|
||||
request.AddressLine2,
|
||||
request.CompanyName,
|
||||
request.Usage,
|
||||
request.IsDefault,
|
||||
request.AddressMetaJson));
|
||||
return Ok(address);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileAddressesWrite")]
|
||||
[HttpDelete("addresses/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteAddress(Guid id)
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _profileService.DeleteAddressAsync(user.Id, id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileSubscriptionsRead")]
|
||||
[HttpGet("subscriptions")]
|
||||
public async Task<IActionResult> Subscriptions()
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(await _newsletterService.ListSubscriptionsForUserAsync(user.Id));
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileSubscriptionsWrite")]
|
||||
[HttpPost("subscriptions/{id:guid}/unsubscribe")]
|
||||
public async Task<IActionResult> Unsubscribe(Guid id)
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var subscription = await _newsletterService.UnsubscribeForUserAsync(user.Id, id);
|
||||
return subscription is null ? NotFound() : Ok(subscription);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileBasicRead")]
|
||||
[HttpGet("profile/by-email")]
|
||||
public async Task<IActionResult> ProfileByEmail([FromQuery] string email)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var profile = await _profileService.GetProfileAsync(user.Id);
|
||||
return Ok(profile);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileBasicWrite")]
|
||||
[HttpPost("profile/by-email")]
|
||||
public async Task<IActionResult> SaveProfileByEmail([FromQuery] string email, [FromBody] SaveProfileRequest request)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var profile = await _profileService.SaveProfileAsync(user.Id, new SaveUserProfileRequest(
|
||||
request.LastName,
|
||||
request.FirstName,
|
||||
request.NickName,
|
||||
request.MobilePhone,
|
||||
request.LandlinePhone,
|
||||
request.DateOfBirth,
|
||||
request.Gender,
|
||||
request.CompanyName,
|
||||
request.Department,
|
||||
request.JobTitle,
|
||||
request.CompanyPhone,
|
||||
request.TaxId,
|
||||
request.InvoiceTitle,
|
||||
request.Remark));
|
||||
return Ok(profile);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileAddressesRead")]
|
||||
[HttpGet("addresses/by-email")]
|
||||
public async Task<IActionResult> AddressesByEmail([FromQuery] string email)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(await _profileService.ListAddressesAsync(user.Id));
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileAddressesWrite")]
|
||||
[HttpPost("addresses/by-email")]
|
||||
public async Task<IActionResult> SaveAddressByEmail([FromQuery] string email, [FromBody] SaveAddressRequest request)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var address = await _profileService.SaveAddressAsync(user.Id, new SaveUserAddressRequest(
|
||||
request.Id,
|
||||
request.Label,
|
||||
request.RecipientName,
|
||||
request.RecipientPhone,
|
||||
request.CountryCode,
|
||||
request.PostalCode,
|
||||
request.StateRegion,
|
||||
request.City,
|
||||
request.District,
|
||||
request.AddressLine1,
|
||||
request.AddressLine2,
|
||||
request.CompanyName,
|
||||
request.Usage,
|
||||
request.IsDefault,
|
||||
request.AddressMetaJson));
|
||||
return Ok(address);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileAddressesWrite")]
|
||||
[HttpDelete("addresses/by-email/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteAddressByEmail(Guid id, [FromQuery] string email)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _profileService.DeleteAddressAsync(user.Id, id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileSubscriptionsRead")]
|
||||
[HttpGet("subscriptions/by-email")]
|
||||
public async Task<IActionResult> SubscriptionsByEmail([FromQuery] string email)
|
||||
{
|
||||
return Ok(await _newsletterService.ListSubscriptionsByEmailAsync(email));
|
||||
}
|
||||
|
||||
[Authorize(Policy = "ProfileSubscriptionsWrite")]
|
||||
[HttpPost("subscriptions/by-email/{id:guid}/unsubscribe")]
|
||||
public async Task<IActionResult> UnsubscribeByEmail(Guid id, [FromQuery] string email)
|
||||
{
|
||||
var subscription = await _newsletterService.UnsubscribeByEmailAsync(email, id);
|
||||
return subscription is null ? NotFound() : Ok(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
using OpenIddict.Abstractions;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace MemberCenter.Api.Extensions;
|
||||
|
||||
@ -29,11 +28,4 @@ public static class ClaimsExtensions
|
||||
_ => new[] { OpenIddictConstants.Destinations.AccessToken }
|
||||
};
|
||||
}
|
||||
|
||||
public static bool HasScope(this ClaimsPrincipal user, string scope)
|
||||
{
|
||||
var values = user.FindAll(OpenIddictConstants.Claims.Scope)
|
||||
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
return values.Contains(scope, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using System.Threading.RateLimiting;
|
||||
using MemberCenter.Infrastructure.Configuration;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using MemberCenter.Infrastructure.Services;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Constants;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
@ -24,10 +18,6 @@ var issuer = builder.Configuration["Auth:Issuer"];
|
||||
var issuerUri = ParseAbsoluteUriOrThrow(issuer, "Auth:Issuer");
|
||||
var allowInsecureHttp = builder.Configuration.GetValue("Auth:AllowInsecureHttp", false);
|
||||
|
||||
builder.Services.AddDataProtection()
|
||||
.SetApplicationName("MemberCenter")
|
||||
.PersistKeysToDbContext<MemberCenterDbContext>();
|
||||
|
||||
builder.Services.AddDbContext<MemberCenterDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Configuration.GetConnectionString("Default")
|
||||
@ -46,9 +36,6 @@ builder.Services
|
||||
options.Password.RequireUppercase = true;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
options.Password.RequiredLength = 8;
|
||||
options.Lockout.AllowedForNewUsers = true;
|
||||
options.Lockout.MaxFailedAccessAttempts = 5;
|
||||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
|
||||
})
|
||||
.AddEntityFrameworkStores<MemberCenterDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
@ -60,13 +47,6 @@ builder.Services.AddAuthentication(options =>
|
||||
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||
});
|
||||
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.Cookie.Path = "/";
|
||||
options.Cookie.SameSite = SameSiteMode.None;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenIddict()
|
||||
.AddCore(options =>
|
||||
{
|
||||
@ -98,23 +78,12 @@ builder.Services.AddOpenIddict()
|
||||
OpenIddictConstants.Scopes.OpenId,
|
||||
OpenIddictConstants.Scopes.Email,
|
||||
OpenIddictConstants.Scopes.Profile,
|
||||
"profile:basic.read",
|
||||
"profile:basic.write",
|
||||
"profile:addresses.read",
|
||||
"profile:addresses.write",
|
||||
"profile:subscriptions.read",
|
||||
"profile:subscriptions.write",
|
||||
"newsletter:list.read",
|
||||
"newsletter:send.write",
|
||||
"newsletter:send.read",
|
||||
"newsletter:events.read",
|
||||
"newsletter:events.write",
|
||||
"newsletter:events.write.global",
|
||||
"files:upload.write",
|
||||
"files:download.read",
|
||||
"files:download.delegate",
|
||||
"files:metadata.read",
|
||||
"files:delete");
|
||||
"newsletter:events.write.global");
|
||||
|
||||
options.AddDevelopmentEncryptionCertificate();
|
||||
options.AddDevelopmentSigningCertificate();
|
||||
@ -140,16 +109,7 @@ builder.Services.AddOpenIddict()
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("Admin", policy => policy.RequireRole("admin", "superuser"));
|
||||
options.AddPolicy("Superuser", policy => policy.RequireRole("superuser"));
|
||||
options.AddPolicy("ProfileBasicRead", policy => policy.RequireAssertion(context => context.User.HasScope("profile:basic.read")));
|
||||
options.AddPolicy("ProfileBasicWrite", policy => policy.RequireAssertion(context => context.User.HasScope("profile:basic.write")));
|
||||
options.AddPolicy("ProfileAddressesRead", policy => policy.RequireAssertion(context => context.User.HasScope("profile:addresses.read")));
|
||||
options.AddPolicy("ProfileAddressesWrite", policy => policy.RequireAssertion(context => context.User.HasScope("profile:addresses.write")));
|
||||
options.AddPolicy("ProfileSubscriptionsRead", policy => policy.RequireAssertion(context => context.User.HasScope("profile:subscriptions.read")));
|
||||
options.AddPolicy("ProfileSubscriptionsWrite", policy => policy.RequireAssertion(context => context.User.HasScope("profile:subscriptions.write")));
|
||||
options.AddPolicy("FilesDownloadDelegate", policy => policy.RequireAssertion(context => context.User.HasScope("files:download.delegate")));
|
||||
options.AddPolicy("FilesDownloadRead", policy => policy.RequireAssertion(context => context.User.HasScope("files:download.read")));
|
||||
options.AddPolicy("Admin", policy => policy.RequireRole("admin"));
|
||||
});
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
@ -159,55 +119,17 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.OnRejected = static async (context, token) =>
|
||||
{
|
||||
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||
{
|
||||
context.HttpContext.Response.Headers.RetryAfter = Math.Ceiling(retryAfter.TotalSeconds).ToString();
|
||||
}
|
||||
|
||||
await context.HttpContext.Response.WriteAsync("Too many requests.", token);
|
||||
};
|
||||
|
||||
options.AddPolicy(RateLimitPolicyNames.PublicAuthRegister, context =>
|
||||
CreateFixedWindowLimiter(context, "api-auth-register", permitLimit: 5, TimeSpan.FromMinutes(15)));
|
||||
|
||||
options.AddPolicy(RateLimitPolicyNames.PublicAuthRecovery, context =>
|
||||
CreateFixedWindowLimiter(context, "api-auth-recovery", permitLimit: 5, TimeSpan.FromMinutes(15)));
|
||||
|
||||
options.AddPolicy(RateLimitPolicyNames.PublicNewsletterSubscribe, context =>
|
||||
CreateFixedWindowLimiter(context, "api-newsletter-subscribe", permitLimit: 20, TimeSpan.FromMinutes(10)));
|
||||
|
||||
options.AddPolicy(RateLimitPolicyNames.PublicNewsletterUnsubscribeToken, context =>
|
||||
CreateFixedWindowLimiter(context, "api-newsletter-unsubscribe-token", permitLimit: 10, TimeSpan.FromMinutes(10)));
|
||||
});
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddScoped<IAuditLogWriter, AuditLogWriter>();
|
||||
builder.Services.AddScoped<IAccountGovernanceService, AccountGovernanceService>();
|
||||
builder.Services.AddScoped<IAccountEmailTemplateService, AccountEmailTemplateService>();
|
||||
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddScoped<IAccountEmailService, AccountEmailService>();
|
||||
builder.Services.AddScoped<INewsletterService, NewsletterService>();
|
||||
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
|
||||
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
|
||||
builder.Services.AddScoped<ITenantService, TenantService>();
|
||||
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
|
||||
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
|
||||
builder.Services.AddScoped<IProfileService, ProfileService>();
|
||||
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
|
||||
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
|
||||
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
await EnsureAuthRegistryDefaultsAsync(app.Services);
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
if (!string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
@ -226,45 +148,7 @@ app.Use(async (context, next) =>
|
||||
});
|
||||
|
||||
app.UseRouting();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
if (context.User.HasClaim(claim => claim.Type == "client_usage"))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
var subject = context.User.FindFirstValue(OpenIddictConstants.Claims.Subject)
|
||||
?? context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
if (Guid.TryParse(subject, out var userId))
|
||||
{
|
||||
var userManager = context.RequestServices.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null || user.DisabledAt.HasValue)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsync("Account is disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenSecurityStamp = context.User.FindFirst("AspNet.Identity.SecurityStamp")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(tokenSecurityStamp)
|
||||
&& !string.Equals(tokenSecurityStamp, user.SecurityStamp, StringComparison.Ordinal))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await context.Response.WriteAsync("Session has been invalidated.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
@ -311,34 +195,3 @@ static bool IsOpenIddictRequest(PathString path)
|
||||
|| path.StartsWithSegments("/oauth", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWithSegments("/auth", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static RateLimitPartition<string> CreateFixedWindowLimiter(
|
||||
HttpContext context,
|
||||
string policyPrefix,
|
||||
int permitLimit,
|
||||
TimeSpan window)
|
||||
{
|
||||
var identifier = context.User.Identity?.IsAuthenticated == true
|
||||
? context.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? context.User.Identity?.Name
|
||||
?? context.Connection.RemoteIpAddress?.ToString()
|
||||
?? "unknown"
|
||||
: context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
var partitionKey = $"{policyPrefix}:{identifier}";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = permitLimit,
|
||||
Window = window,
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
});
|
||||
}
|
||||
|
||||
static async Task EnsureAuthRegistryDefaultsAsync(IServiceProvider services)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
|
||||
await registry.EnsureDefaultsAsync();
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:7850",
|
||||
"applicationUrl": "http://localhost:5050",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IAccountEmailService
|
||||
{
|
||||
Task SendVerificationEmailAsync(Guid userId, string? fallbackBaseUrl = null);
|
||||
Task SendPasswordResetEmailAsync(Guid userId, string? fallbackBaseUrl = null);
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
using MemberCenter.Application.Models.Email;
|
||||
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IAccountEmailTemplateService
|
||||
{
|
||||
EmailTemplate BuildVerificationEmail(string verifyUrl);
|
||||
EmailTemplate BuildPasswordResetEmail(string resetUrl);
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
using MemberCenter.Application.Models.Admin;
|
||||
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IAccountGovernanceService
|
||||
{
|
||||
Task<IReadOnlyList<UserGovernanceSummaryDto>> ListUsersAsync(string? search = null, int take = 200);
|
||||
Task SetAdminAsync(Guid actorUserId, Guid targetUserId, bool enabled);
|
||||
Task SetDisabledAsync(Guid actorUserId, Guid targetUserId, bool disabled);
|
||||
Task ResetPasswordAsync(Guid actorUserId, Guid targetUserId, string newPassword);
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
using MemberCenter.Application.Models.Account;
|
||||
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IAccountProvisioningService
|
||||
{
|
||||
Task<AccountProvisioningResult> RegisterLocalAsync(string email, string password);
|
||||
Task<AccountProvisioningResult> ProvisionExternalLoginAsync(string loginProvider, string providerKey, string? email, bool emailVerified);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IAuditLogWriter
|
||||
{
|
||||
Task WriteAsync(string actorType, Guid? actorId, string action, object payload);
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IAuthResourceRegistryService
|
||||
{
|
||||
Task EnsureDefaultsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<string>> ResolveAudiencesAsync(IEnumerable<string> scopes, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<string>> GetAllowedScopesForUsageAsync(string usage, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IEmailSender
|
||||
{
|
||||
Task<int> SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null);
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
using MemberCenter.Application.Models.Profile;
|
||||
using MemberCenter.Application.Models.Newsletter;
|
||||
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
@ -14,8 +13,4 @@ public interface INewsletterService
|
||||
Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email);
|
||||
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences);
|
||||
Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId);
|
||||
Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsForUserAsync(Guid userId);
|
||||
Task<UserSubscriptionSummaryDto?> UnsubscribeForUserAsync(Guid userId, Guid subscriptionId);
|
||||
Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsByEmailAsync(string email);
|
||||
Task<UserSubscriptionSummaryDto?> UnsubscribeByEmailAsync(string email, Guid subscriptionId);
|
||||
}
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
using MemberCenter.Application.Models.Profile;
|
||||
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IProfileService
|
||||
{
|
||||
Task<UserProfileDto> GetProfileAsync(Guid userId);
|
||||
Task<UserProfileDto> SaveProfileAsync(Guid userId, SaveUserProfileRequest request);
|
||||
Task<IReadOnlyList<UserAddressDto>> ListAddressesAsync(Guid userId);
|
||||
Task<UserAddressDto?> GetAddressAsync(Guid userId, Guid addressId);
|
||||
Task<UserAddressDto> SaveAddressAsync(Guid userId, SaveUserAddressRequest request);
|
||||
Task DeleteAddressAsync(Guid userId, Guid addressId);
|
||||
}
|
||||
@ -5,7 +5,5 @@ namespace MemberCenter.Application.Abstractions;
|
||||
public interface ISecuritySettingsService
|
||||
{
|
||||
Task<SecuritySettingsDto> GetAsync();
|
||||
Task<SmtpSettingsDto> GetSmtpSettingsAsync();
|
||||
Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null);
|
||||
Task<int> SendTestEmailAsync(string toEmail, Guid? actorUserId = null);
|
||||
Task SaveAsync(SecuritySettingsDto settings);
|
||||
}
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
namespace MemberCenter.Application.Constants;
|
||||
|
||||
public static class RateLimitPolicyNames
|
||||
{
|
||||
public const string PublicAuthLogin = "public-auth-login";
|
||||
public const string PublicAuthRegister = "public-auth-register";
|
||||
public const string PublicAuthRecovery = "public-auth-recovery";
|
||||
public const string PublicNewsletterSubscribe = "public-newsletter-subscribe";
|
||||
public const string PublicNewsletterUnsubscribeToken = "public-newsletter-unsubscribe-token";
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
namespace MemberCenter.Application.Models.Account;
|
||||
|
||||
public sealed record AccountProvisioningResult(
|
||||
bool Succeeded,
|
||||
Guid? UserId,
|
||||
string? Email,
|
||||
bool EmailConfirmed,
|
||||
bool CreatedUser,
|
||||
bool LinkedExternalLogin,
|
||||
int LinkedSubscriptionsCount,
|
||||
IReadOnlyList<string> Errors);
|
||||
@ -5,5 +5,4 @@ public sealed record AuditLogDto(
|
||||
string ActorType,
|
||||
Guid? ActorId,
|
||||
string Action,
|
||||
string? PayloadJson,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
@ -2,16 +2,4 @@ namespace MemberCenter.Application.Models.Admin;
|
||||
|
||||
public sealed record SecuritySettingsDto(
|
||||
int AccessTokenMinutes,
|
||||
int RefreshTokenDays,
|
||||
string PublicBaseUrl,
|
||||
string SmtpRelayHost,
|
||||
int SmtpRelayPort,
|
||||
bool SmtpUseTls,
|
||||
bool SmtpUseSsl,
|
||||
int SmtpTimeoutSeconds,
|
||||
string SmtpUsername,
|
||||
string SmtpPassword,
|
||||
bool HasSmtpPassword,
|
||||
string SenderName,
|
||||
string SenderEmail,
|
||||
string TestToEmail);
|
||||
int RefreshTokenDays);
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
namespace MemberCenter.Application.Models.Admin;
|
||||
|
||||
public sealed record SmtpSettingsDto(
|
||||
string PublicBaseUrl,
|
||||
string RelayHost,
|
||||
int RelayPort,
|
||||
bool UseTls,
|
||||
bool UseSsl,
|
||||
int TimeoutSeconds,
|
||||
string Username,
|
||||
string Password,
|
||||
bool HasPassword,
|
||||
string SenderName,
|
||||
string SenderEmail);
|
||||
@ -1,18 +0,0 @@
|
||||
namespace MemberCenter.Application.Models.Admin;
|
||||
|
||||
public sealed record UserGovernanceSummaryDto(
|
||||
Guid UserId,
|
||||
string Email,
|
||||
string? LastName,
|
||||
string? FirstName,
|
||||
string? NickName,
|
||||
bool EmailConfirmed,
|
||||
bool IsAdmin,
|
||||
bool IsSuperuser,
|
||||
bool IsDisabled,
|
||||
bool IsBlacklisted,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastLoginAt,
|
||||
DateTimeOffset? LastSeenAt,
|
||||
DateTimeOffset? DisabledAt,
|
||||
string? DisabledBy);
|
||||
@ -1,6 +0,0 @@
|
||||
namespace MemberCenter.Application.Models.Email;
|
||||
|
||||
public sealed record EmailTemplate(
|
||||
string Subject,
|
||||
string TextBody,
|
||||
string HtmlBody);
|
||||
@ -1,18 +0,0 @@
|
||||
namespace MemberCenter.Application.Models.Profile;
|
||||
|
||||
public sealed record SaveUserAddressRequest(
|
||||
Guid? Id,
|
||||
string Label,
|
||||
string RecipientName,
|
||||
string RecipientPhone,
|
||||
string CountryCode,
|
||||
string? PostalCode,
|
||||
string? StateRegion,
|
||||
string? City,
|
||||
string? District,
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string? CompanyName,
|
||||
string Usage,
|
||||
bool IsDefault,
|
||||
string? AddressMetaJson);
|
||||
@ -1,17 +0,0 @@
|
||||
namespace MemberCenter.Application.Models.Profile;
|
||||
|
||||
public sealed record SaveUserProfileRequest(
|
||||
string LastName,
|
||||
string FirstName,
|
||||
string? NickName,
|
||||
string? MobilePhone,
|
||||
string? LandlinePhone,
|
||||
DateOnly? DateOfBirth,
|
||||
string Gender,
|
||||
string? CompanyName,
|
||||
string? Department,
|
||||
string? JobTitle,
|
||||
string? CompanyPhone,
|
||||
string? TaxId,
|
||||
string? InvoiceTitle,
|
||||
string? Remark);
|
||||
@ -1,19 +0,0 @@
|
||||
namespace MemberCenter.Application.Models.Profile;
|
||||
|
||||
public sealed record UserAddressDto(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string Label,
|
||||
string RecipientName,
|
||||
string RecipientPhone,
|
||||
string CountryCode,
|
||||
string? PostalCode,
|
||||
string? StateRegion,
|
||||
string? City,
|
||||
string? District,
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string? CompanyName,
|
||||
string Usage,
|
||||
bool IsDefault,
|
||||
string? AddressMetaJson);
|
||||
@ -1,19 +0,0 @@
|
||||
namespace MemberCenter.Application.Models.Profile;
|
||||
|
||||
public sealed record UserProfileDto(
|
||||
Guid UserId,
|
||||
string Email,
|
||||
string LastName,
|
||||
string FirstName,
|
||||
string? NickName,
|
||||
string? MobilePhone,
|
||||
string? LandlinePhone,
|
||||
DateOnly? DateOfBirth,
|
||||
string Gender,
|
||||
string? CompanyName,
|
||||
string? Department,
|
||||
string? JobTitle,
|
||||
string? CompanyPhone,
|
||||
string? TaxId,
|
||||
string? InvoiceTitle,
|
||||
string? Remark);
|
||||
@ -1,11 +0,0 @@
|
||||
namespace MemberCenter.Application.Models.Profile;
|
||||
|
||||
public sealed record UserSubscriptionSummaryDto(
|
||||
Guid Id,
|
||||
Guid ListId,
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
string ListName,
|
||||
string Email,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt);
|
||||
@ -1,10 +0,0 @@
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class AuthClientUsagePermission
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Usage { get; set; } = string.Empty;
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class AuthResource
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Audience { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool RequireTenant { get; set; }
|
||||
public bool AllowDelegatedToken { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public List<AuthResourceScope> Scopes { get; set; } = new();
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class AuthResourceScope
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ResourceId { get; set; }
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public AuthResource? Resource { get; set; }
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class FileAccessDownloadToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TokenHash { get; set; } = string.Empty;
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public string? FileId { get; set; }
|
||||
public string? ObjectKey { get; set; }
|
||||
public string Method { get; set; } = "GET";
|
||||
public string Scope { get; set; } = "files:download.read";
|
||||
public string? IssuedByClientId { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public DateTimeOffset? LastValidatedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class UserAddress
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string RecipientName { get; set; } = string.Empty;
|
||||
public string RecipientPhone { get; set; } = string.Empty;
|
||||
public string CountryCode { get; set; } = "TW";
|
||||
public string? PostalCode { get; set; }
|
||||
public string? StateRegion { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? District { get; set; }
|
||||
public string AddressLine1 { get; set; } = string.Empty;
|
||||
public string? AddressLine2 { get; set; }
|
||||
public string? CompanyName { get; set; }
|
||||
public string Usage { get; set; } = "shipping";
|
||||
public bool IsDefault { get; set; }
|
||||
public JsonDocument? AddressMetaJson { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class UserProfile
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string? NickName { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? LandlinePhone { get; set; }
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public string Gender { get; set; } = "unspecified";
|
||||
public string? CompanyName { get; set; }
|
||||
public string? Department { get; set; }
|
||||
public string? JobTitle { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? TaxId { get; set; }
|
||||
public string? InvoiceTitle { get; set; }
|
||||
public string? Remark { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
using MemberCenter.Domain.Entities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Identity;
|
||||
@ -9,11 +8,4 @@ public class ApplicationUser : IdentityUser<Guid>
|
||||
public bool IsBlacklisted { get; set; }
|
||||
public DateTimeOffset? BlacklistedAt { get; set; }
|
||||
public string? BlacklistedBy { get; set; }
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
public DateTimeOffset? LastSeenAt { get; set; }
|
||||
public DateTimeOffset? DisabledAt { get; set; }
|
||||
public string? DisabledBy { get; set; }
|
||||
|
||||
public UserProfile? Profile { get; set; }
|
||||
public List<UserAddress> Addresses { get; set; } = new();
|
||||
}
|
||||
|
||||
@ -6,14 +6,12 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
|
||||
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Persistence;
|
||||
|
||||
public class MemberCenterDbContext
|
||||
: IdentityDbContext<ApplicationUser, ApplicationRole, Guid>, IDataProtectionKeyContext
|
||||
: IdentityDbContext<ApplicationUser, ApplicationRole, Guid>
|
||||
{
|
||||
public MemberCenterDbContext(DbContextOptions<MemberCenterDbContext> options)
|
||||
: base(options)
|
||||
@ -17,18 +16,11 @@ public class MemberCenterDbContext
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<NewsletterList> NewsletterLists => Set<NewsletterList>();
|
||||
public DbSet<NewsletterSubscription> NewsletterSubscriptions => Set<NewsletterSubscription>();
|
||||
public DbSet<UserProfile> UserProfiles => Set<UserProfile>();
|
||||
public DbSet<UserAddress> UserAddresses => Set<UserAddress>();
|
||||
public DbSet<EmailBlacklist> EmailBlacklist => Set<EmailBlacklist>();
|
||||
public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>();
|
||||
public DbSet<UnsubscribeToken> UnsubscribeTokens => Set<UnsubscribeToken>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<SystemFlag> SystemFlags => Set<SystemFlag>();
|
||||
public DbSet<AuthResource> AuthResources => Set<AuthResource>();
|
||||
public DbSet<AuthResourceScope> AuthResourceScopes => Set<AuthResourceScope>();
|
||||
public DbSet<AuthClientUsagePermission> AuthClientUsagePermissions => Set<AuthClientUsagePermission>();
|
||||
public DbSet<FileAccessDownloadToken> FileAccessDownloadTokens => Set<FileAccessDownloadToken>();
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
@ -84,67 +76,6 @@ public class MemberCenterDbContext
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<UserProfile>(entity =>
|
||||
{
|
||||
entity.ToTable("user_profiles");
|
||||
entity.HasKey(x => x.UserId);
|
||||
entity.Property(x => x.LastName).IsRequired().HasMaxLength(100);
|
||||
entity.Property(x => x.FirstName).IsRequired().HasMaxLength(100);
|
||||
entity.Property(x => x.NickName).HasMaxLength(100);
|
||||
entity.Property(x => x.MobilePhone).HasMaxLength(50);
|
||||
entity.Property(x => x.LandlinePhone).HasMaxLength(50);
|
||||
entity.Property(x => x.Gender).IsRequired().HasMaxLength(20).HasDefaultValue("unspecified");
|
||||
entity.Property(x => x.CompanyName).HasMaxLength(200);
|
||||
entity.Property(x => x.Department).HasMaxLength(200);
|
||||
entity.Property(x => x.JobTitle).HasMaxLength(200);
|
||||
entity.Property(x => x.CompanyPhone).HasMaxLength(50);
|
||||
entity.Property(x => x.TaxId).HasMaxLength(32);
|
||||
entity.Property(x => x.InvoiceTitle).HasMaxLength(200);
|
||||
entity.Property(x => x.Remark).HasMaxLength(1000);
|
||||
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
|
||||
entity.HasOne<ApplicationUser>()
|
||||
.WithOne(x => x.Profile)
|
||||
.HasForeignKey<UserProfile>(x => x.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<UserAddress>(entity =>
|
||||
{
|
||||
entity.ToTable("user_addresses");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Label).IsRequired().HasMaxLength(100);
|
||||
entity.Property(x => x.RecipientName).IsRequired().HasMaxLength(100);
|
||||
entity.Property(x => x.RecipientPhone).IsRequired().HasMaxLength(50);
|
||||
entity.Property(x => x.CountryCode).IsRequired().HasMaxLength(2);
|
||||
entity.Property(x => x.PostalCode).HasMaxLength(20);
|
||||
entity.Property(x => x.StateRegion).HasMaxLength(100);
|
||||
entity.Property(x => x.City).HasMaxLength(100);
|
||||
entity.Property(x => x.District).HasMaxLength(100);
|
||||
entity.Property(x => x.AddressLine1).IsRequired().HasMaxLength(255);
|
||||
entity.Property(x => x.AddressLine2).HasMaxLength(255);
|
||||
entity.Property(x => x.CompanyName).HasMaxLength(200);
|
||||
entity.Property(x => x.Usage).IsRequired().HasMaxLength(20).HasDefaultValue("shipping");
|
||||
entity.Property(x => x.IsDefault).HasDefaultValue(false);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
||||
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
|
||||
entity.Property(x => x.AddressMetaJson)
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => v == null ? null : v.RootElement.GetRawText(),
|
||||
v => string.IsNullOrWhiteSpace(v) ? null : System.Text.Json.JsonDocument.Parse(v, new System.Text.Json.JsonDocumentOptions()));
|
||||
entity.HasIndex(x => x.UserId).HasDatabaseName("idx_user_addresses_user_id");
|
||||
entity.HasIndex(x => new { x.UserId, x.Usage })
|
||||
.HasDatabaseName("idx_user_addresses_user_id_usage");
|
||||
entity.HasIndex(x => new { x.UserId, x.IsDefault })
|
||||
.IsUnique()
|
||||
.HasFilter("\"IsDefault\" = true")
|
||||
.HasDatabaseName("ux_user_addresses_default_per_user");
|
||||
entity.HasOne<ApplicationUser>()
|
||||
.WithMany(x => x.Addresses)
|
||||
.HasForeignKey(x => x.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<EmailBlacklist>(entity =>
|
||||
{
|
||||
entity.ToTable("email_blacklist");
|
||||
@ -205,73 +136,6 @@ public class MemberCenterDbContext
|
||||
entity.HasIndex(x => x.Key).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<AuthResource>(entity =>
|
||||
{
|
||||
entity.ToTable("auth_resources");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Name).IsRequired().HasMaxLength(100);
|
||||
entity.Property(x => x.Audience).IsRequired().HasMaxLength(200);
|
||||
entity.Property(x => x.Description).HasMaxLength(500);
|
||||
entity.Property(x => x.RequireTenant).HasDefaultValue(false);
|
||||
entity.Property(x => x.AllowDelegatedToken).HasDefaultValue(false);
|
||||
entity.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
||||
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
|
||||
entity.HasIndex(x => x.Name).IsUnique();
|
||||
entity.HasIndex(x => x.Audience).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<AuthResourceScope>(entity =>
|
||||
{
|
||||
entity.ToTable("auth_resource_scopes");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Scope).IsRequired().HasMaxLength(200);
|
||||
entity.Property(x => x.Description).HasMaxLength(500);
|
||||
entity.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
||||
entity.HasIndex(x => new { x.ResourceId, x.Scope }).IsUnique();
|
||||
entity.HasIndex(x => x.Scope).HasDatabaseName("idx_auth_resource_scopes_scope");
|
||||
entity.HasOne(x => x.Resource)
|
||||
.WithMany(x => x.Scopes)
|
||||
.HasForeignKey(x => x.ResourceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<AuthClientUsagePermission>(entity =>
|
||||
{
|
||||
entity.ToTable("auth_client_usage_permissions");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Usage).IsRequired().HasMaxLength(100);
|
||||
entity.Property(x => x.Scope).IsRequired().HasMaxLength(200);
|
||||
entity.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
||||
entity.HasIndex(x => new { x.Usage, x.Scope }).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<FileAccessDownloadToken>(entity =>
|
||||
{
|
||||
entity.ToTable("file_access_download_tokens");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.TokenHash).IsRequired();
|
||||
entity.Property(x => x.FileId).HasMaxLength(200);
|
||||
entity.Property(x => x.ObjectKey).HasMaxLength(1000);
|
||||
entity.Property(x => x.Method).IsRequired().HasMaxLength(10).HasDefaultValue("GET");
|
||||
entity.Property(x => x.Scope).IsRequired().HasMaxLength(200).HasDefaultValue("files:download.read");
|
||||
entity.Property(x => x.IssuedByClientId).HasMaxLength(200);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
||||
entity.HasIndex(x => x.TokenHash).IsUnique();
|
||||
entity.HasIndex(x => x.ExpiresAt).HasDatabaseName("idx_file_access_download_tokens_expires_at");
|
||||
entity.HasIndex(x => new { x.TenantId, x.UserId }).HasDatabaseName("idx_file_access_download_tokens_tenant_user");
|
||||
entity.HasOne<Tenant>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne<ApplicationUser>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<ApplicationUser>(entity =>
|
||||
{
|
||||
entity.ToTable("users");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,159 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserProfilesAndAddresses : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "DisabledAt",
|
||||
table: "users",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DisabledBy",
|
||||
table: "users",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "LastLoginAt",
|
||||
table: "users",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "LastSeenAt",
|
||||
table: "users",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "user_addresses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
RecipientName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
RecipientPhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
CountryCode = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: false),
|
||||
PostalCode = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||
StateRegion = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
District = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
AddressLine1 = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
AddressLine2 = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
||||
CompanyName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
Usage = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "shipping"),
|
||||
IsDefault = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
AddressMetaJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_user_addresses", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_user_addresses_users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "user_profiles",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
LastName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
FirstName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
NickName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
MobilePhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
LandlinePhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
DateOfBirth = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
Gender = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "unspecified"),
|
||||
CompanyName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
Department = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
JobTitle = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
CompanyPhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
TaxId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
InvoiceTitle = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
Remark = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_user_profiles", x => x.UserId);
|
||||
table.ForeignKey(
|
||||
name: "FK_user_profiles_users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO user_profiles ("UserId", "LastName", "FirstName", "Gender", "UpdatedAt")
|
||||
SELECT "Id", '', '', 'unspecified', now()
|
||||
FROM users
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_profiles
|
||||
WHERE user_profiles."UserId" = users."Id"
|
||||
);
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_user_addresses_user_id",
|
||||
table: "user_addresses",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_user_addresses_user_id_usage",
|
||||
table: "user_addresses",
|
||||
columns: new[] { "UserId", "Usage" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_user_addresses_default_per_usage",
|
||||
table: "user_addresses",
|
||||
columns: new[] { "UserId", "Usage", "IsDefault" },
|
||||
unique: true,
|
||||
filter: "\"IsDefault\" = true");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "user_addresses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "user_profiles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DisabledAt",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DisabledBy",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastLoginAt",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastSeenAt",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(MemberCenterDbContext))]
|
||||
[Migration("20260417043000_ChangeUserAddressDefaultToSinglePerUser")]
|
||||
public partial class ChangeUserAddressDefaultToSinglePerUser : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
WITH ranked_defaults AS (
|
||||
SELECT "Id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "UserId"
|
||||
ORDER BY "UpdatedAt" DESC, "CreatedAt" DESC, "Id"
|
||||
) AS rn
|
||||
FROM user_addresses
|
||||
WHERE "IsDefault" = true
|
||||
)
|
||||
UPDATE user_addresses ua
|
||||
SET "IsDefault" = false,
|
||||
"UpdatedAt" = now()
|
||||
FROM ranked_defaults rd
|
||||
WHERE ua."Id" = rd."Id"
|
||||
AND rd.rn > 1;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
WITH users_without_default AS (
|
||||
SELECT ua."UserId"
|
||||
FROM user_addresses ua
|
||||
GROUP BY ua."UserId"
|
||||
HAVING BOOL_OR(ua."IsDefault") = false
|
||||
),
|
||||
replacement AS (
|
||||
SELECT ranked."Id"
|
||||
FROM (
|
||||
SELECT ua."Id",
|
||||
ua."UserId",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY ua."UserId"
|
||||
ORDER BY ua."UpdatedAt" DESC, ua."CreatedAt" DESC, ua."Id"
|
||||
) AS rn
|
||||
FROM user_addresses ua
|
||||
INNER JOIN users_without_default uwd ON uwd."UserId" = ua."UserId"
|
||||
) ranked
|
||||
WHERE ranked.rn = 1
|
||||
)
|
||||
UPDATE user_addresses ua
|
||||
SET "IsDefault" = true,
|
||||
"UpdatedAt" = now()
|
||||
FROM replacement r
|
||||
WHERE ua."Id" = r."Id";
|
||||
""");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ux_user_addresses_default_per_usage",
|
||||
table: "user_addresses");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_user_addresses_default_per_user",
|
||||
table: "user_addresses",
|
||||
columns: new[] { "UserId", "IsDefault" },
|
||||
unique: true,
|
||||
filter: "\"IsDefault\" = true");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ux_user_addresses_default_per_user",
|
||||
table: "user_addresses");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_user_addresses_default_per_usage",
|
||||
table: "user_addresses",
|
||||
columns: new[] { "UserId", "Usage", "IsDefault" },
|
||||
unique: true,
|
||||
filter: "\"IsDefault\" = true");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,113 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuthResourceRegistry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "auth_client_usage_permissions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Usage = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_auth_client_usage_permissions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "auth_resources",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Audience = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
RequireTenant = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
AllowDelegatedToken = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_auth_resources", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "auth_resource_scopes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ResourceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_auth_resource_scopes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_auth_resource_scopes_auth_resources_ResourceId",
|
||||
column: x => x.ResourceId,
|
||||
principalTable: "auth_resources",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_auth_client_usage_permissions_Usage_Scope",
|
||||
table: "auth_client_usage_permissions",
|
||||
columns: new[] { "Usage", "Scope" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_auth_resource_scopes_scope",
|
||||
table: "auth_resource_scopes",
|
||||
column: "Scope");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_auth_resource_scopes_ResourceId_Scope",
|
||||
table: "auth_resource_scopes",
|
||||
columns: new[] { "ResourceId", "Scope" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_auth_resources_Audience",
|
||||
table: "auth_resources",
|
||||
column: "Audience",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_auth_resources_Name",
|
||||
table: "auth_resources",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "auth_client_usage_permissions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "auth_resource_scopes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "auth_resources");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,78 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFileAccessDownloadTokens : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "file_access_download_tokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TokenHash = table.Column<string>(type: "text", nullable: false),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
ObjectKey = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
Method = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false, defaultValue: "GET"),
|
||||
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false, defaultValue: "files:download.read"),
|
||||
IssuedByClientId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LastValidatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_file_access_download_tokens", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_file_access_download_tokens_tenants_TenantId",
|
||||
column: x => x.TenantId,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_file_access_download_tokens_users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_file_access_download_tokens_expires_at",
|
||||
table: "file_access_download_tokens",
|
||||
column: "ExpiresAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_file_access_download_tokens_tenant_user",
|
||||
table: "file_access_download_tokens",
|
||||
columns: new[] { "TenantId", "UserId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_file_access_download_tokens_TokenHash",
|
||||
table: "file_access_download_tokens",
|
||||
column: "TokenHash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_file_access_download_tokens_UserId",
|
||||
table: "file_access_download_tokens",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "file_access_download_tokens");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,36 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDataProtectionKeys : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DataProtectionKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
FriendlyName = table.Column<string>(type: "text", nullable: true),
|
||||
Xml = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DataProtectionKeys");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -54,135 +54,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("audit_logs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthClientUsagePermission", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Usage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Usage", "Scope")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("auth_client_usage_permissions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AllowDelegatedToken")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Audience")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("RequireTenant")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Audience")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("auth_resources", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<Guid>("ResourceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Scope")
|
||||
.HasDatabaseName("idx_auth_resource_scopes_scope");
|
||||
|
||||
b.HasIndex("ResourceId", "Scope")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("auth_resource_scopes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -251,78 +122,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("email_verifications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("IssuedByClientId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastValidatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Method")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasDefaultValue("GET");
|
||||
|
||||
b.Property<string>("ObjectKey")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasDefaultValue("files:download.read");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt")
|
||||
.HasDatabaseName("idx_file_access_download_tokens_expires_at");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("idx_file_access_download_tokens_tenant_user");
|
||||
|
||||
b.ToTable("file_access_download_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -484,180 +283,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("unsubscribe_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AddressLine1")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("AddressLine2")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("AddressMetaJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("City")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("District")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("PostalCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("RecipientName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("RecipientPhone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("StateRegion")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Usage")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("shipping");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("idx_user_addresses_user_id");
|
||||
|
||||
b.HasIndex("UserId", "IsDefault")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ux_user_addresses_default_per_user")
|
||||
.HasFilter("\"IsDefault\" = true");
|
||||
|
||||
b.HasIndex("UserId", "Usage")
|
||||
.HasDatabaseName("idx_user_addresses_user_id_usage");
|
||||
|
||||
b.ToTable("user_addresses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("CompanyPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<DateOnly?>("DateOfBirth")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Department")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("unspecified");
|
||||
|
||||
b.Property<string>("InvoiceTitle")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("LandlinePhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("MobilePhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("NickName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Remark")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("TaxId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("user_profiles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -714,12 +339,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTimeOffset?>("DisabledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisabledBy")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
@ -732,12 +351,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@ -783,25 +396,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Xml")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DataProtectionKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -1113,17 +707,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("OpenIddictTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Domain.Entities.AuthResource", "Resource")
|
||||
.WithMany("Scopes")
|
||||
.HasForeignKey("ResourceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Resource");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Domain.Entities.Tenant", null)
|
||||
@ -1133,21 +716,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Domain.Entities.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant")
|
||||
@ -1186,24 +754,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Subscription");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
|
||||
.WithMany("Addresses")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
|
||||
.WithOne("Profile")
|
||||
.HasForeignKey("MemberCenter.Domain.Entities.UserProfile", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)
|
||||
@ -1279,11 +829,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Authorization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b =>
|
||||
{
|
||||
b.Navigation("Scopes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
|
||||
{
|
||||
b.Navigation("Subscriptions");
|
||||
@ -1294,13 +839,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("NewsletterLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b =>
|
||||
{
|
||||
b.Navigation("Addresses");
|
||||
|
||||
b.Navigation("Profile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
|
||||
{
|
||||
b.Navigation("Authorizations");
|
||||
|
||||
@ -1,88 +0,0 @@
|
||||
using System.Net;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class AccountEmailService : IAccountEmailService
|
||||
{
|
||||
private readonly ISecuritySettingsService _securitySettingsService;
|
||||
private readonly IAccountEmailTemplateService _templateService;
|
||||
private readonly IAuditLogWriter _auditLogWriter;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public AccountEmailService(
|
||||
ISecuritySettingsService securitySettingsService,
|
||||
IAccountEmailTemplateService templateService,
|
||||
IAuditLogWriter auditLogWriter,
|
||||
IEmailSender emailSender,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_securitySettingsService = securitySettingsService;
|
||||
_templateService = templateService;
|
||||
_auditLogWriter = auditLogWriter;
|
||||
_emailSender = emailSender;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
public async Task SendVerificationEmailAsync(Guid userId, string? fallbackBaseUrl = null)
|
||||
{
|
||||
var user = await FindUserAsync(userId);
|
||||
var settings = await _securitySettingsService.GetAsync();
|
||||
var baseUrl = ResolveBaseUrl(settings.PublicBaseUrl, fallbackBaseUrl);
|
||||
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
var link = $"{baseUrl}/account/verifyemail?email={Uri.EscapeDataString(user.Email ?? string.Empty)}&token={Uri.EscapeDataString(token)}";
|
||||
var template = _templateService.BuildVerificationEmail(WebUtility.HtmlEncode(link));
|
||||
|
||||
await _emailSender.SendAsync(user.Email ?? string.Empty, template.Subject, template.TextBody, template.HtmlBody);
|
||||
await _auditLogWriter.WriteAsync("user", user.Id, "account.verification_email_sent", new
|
||||
{
|
||||
user_id = user.Id,
|
||||
email = user.Email
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SendPasswordResetEmailAsync(Guid userId, string? fallbackBaseUrl = null)
|
||||
{
|
||||
var user = await FindUserAsync(userId);
|
||||
var settings = await _securitySettingsService.GetAsync();
|
||||
var baseUrl = ResolveBaseUrl(settings.PublicBaseUrl, fallbackBaseUrl);
|
||||
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var link = $"{baseUrl}/account/resetpassword?email={Uri.EscapeDataString(user.Email ?? string.Empty)}&token={Uri.EscapeDataString(token)}";
|
||||
var template = _templateService.BuildPasswordResetEmail(WebUtility.HtmlEncode(link));
|
||||
|
||||
await _emailSender.SendAsync(user.Email ?? string.Empty, template.Subject, template.TextBody, template.HtmlBody);
|
||||
await _auditLogWriter.WriteAsync("user", user.Id, "account.password_reset_email_sent", new
|
||||
{
|
||||
user_id = user.Id,
|
||||
email = user.Email
|
||||
});
|
||||
}
|
||||
|
||||
private static string ResolveBaseUrl(string? configuredBaseUrl, string? fallbackBaseUrl)
|
||||
{
|
||||
var value = !string.IsNullOrWhiteSpace(configuredBaseUrl)
|
||||
? configuredBaseUrl
|
||||
: fallbackBaseUrl;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException("Public base URL is not configured.");
|
||||
}
|
||||
|
||||
return value.TrimEnd('/');
|
||||
}
|
||||
|
||||
private async Task<ApplicationUser> FindUserAsync(Guid userId)
|
||||
{
|
||||
var user = await _userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null || string.IsNullOrWhiteSpace(user.Email))
|
||||
{
|
||||
throw new InvalidOperationException("User not found or email is empty.");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Models.Email;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class AccountEmailTemplateService : IAccountEmailTemplateService
|
||||
{
|
||||
public EmailTemplate BuildVerificationEmail(string verifyUrl)
|
||||
{
|
||||
return new EmailTemplate(
|
||||
"Verify your email",
|
||||
$"Please verify your email by opening this link: {verifyUrl}",
|
||||
$"<p>Please verify your email by clicking <a href=\"{verifyUrl}\">this link</a>.</p>");
|
||||
}
|
||||
|
||||
public EmailTemplate BuildPasswordResetEmail(string resetUrl)
|
||||
{
|
||||
return new EmailTemplate(
|
||||
"Reset your password",
|
||||
$"Use this link to reset your password: {resetUrl}",
|
||||
$"<p>Use <a href=\"{resetUrl}\">this link</a> to reset your password.</p>");
|
||||
}
|
||||
}
|
||||
@ -1,236 +0,0 @@
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Models.Admin;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.EntityFrameworkCore.Models;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class AccountGovernanceService : IAccountGovernanceService
|
||||
{
|
||||
private const string AdminRole = "admin";
|
||||
private const string SuperuserRole = "superuser";
|
||||
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly RoleManager<ApplicationRole> _roleManager;
|
||||
private readonly IAuditLogWriter _auditLogWriter;
|
||||
|
||||
public AccountGovernanceService(
|
||||
MemberCenterDbContext dbContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
RoleManager<ApplicationRole> roleManager,
|
||||
IAuditLogWriter auditLogWriter)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
_auditLogWriter = auditLogWriter;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserGovernanceSummaryDto>> ListUsersAsync(string? search = null, int take = 200)
|
||||
{
|
||||
var query = _dbContext.Users
|
||||
.AsNoTracking()
|
||||
.GroupJoin(
|
||||
_dbContext.UserProfiles.AsNoTracking(),
|
||||
user => user.Id,
|
||||
profile => profile.UserId,
|
||||
(user, profiles) => new
|
||||
{
|
||||
User = user,
|
||||
Profile = profiles.FirstOrDefault()
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim().ToLower();
|
||||
query = query.Where(x =>
|
||||
(x.User.Email ?? string.Empty).ToLower().Contains(term)
|
||||
|| (x.Profile != null && (
|
||||
x.Profile.LastName.ToLower().Contains(term)
|
||||
|| x.Profile.FirstName.ToLower().Contains(term)
|
||||
|| (x.Profile.NickName != null && x.Profile.NickName.ToLower().Contains(term)))));
|
||||
}
|
||||
|
||||
var users = await query
|
||||
.OrderByDescending(x => x.User.CreatedAt)
|
||||
.Take(Math.Clamp(take, 1, 500))
|
||||
.ToListAsync();
|
||||
|
||||
var result = new List<UserGovernanceSummaryDto>(users.Count);
|
||||
foreach (var item in users)
|
||||
{
|
||||
var roles = await _userManager.GetRolesAsync(item.User);
|
||||
result.Add(new UserGovernanceSummaryDto(
|
||||
item.User.Id,
|
||||
item.User.Email ?? string.Empty,
|
||||
item.Profile?.LastName,
|
||||
item.Profile?.FirstName,
|
||||
item.Profile?.NickName,
|
||||
item.User.EmailConfirmed,
|
||||
roles.Contains(AdminRole, StringComparer.OrdinalIgnoreCase),
|
||||
roles.Contains(SuperuserRole, StringComparer.OrdinalIgnoreCase),
|
||||
item.User.DisabledAt.HasValue,
|
||||
item.User.IsBlacklisted,
|
||||
item.User.CreatedAt,
|
||||
item.User.LastLoginAt,
|
||||
item.User.LastSeenAt,
|
||||
item.User.DisabledAt,
|
||||
item.User.DisabledBy));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task SetAdminAsync(Guid actorUserId, Guid targetUserId, bool enabled)
|
||||
{
|
||||
await EnsureSuperuserAsync(actorUserId);
|
||||
await EnsureRoleExistsAsync(AdminRole);
|
||||
|
||||
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
|
||||
?? throw new InvalidOperationException("Target user not found.");
|
||||
await EnsureTargetIsMutableAsync(targetUser);
|
||||
|
||||
var inRole = await _userManager.IsInRoleAsync(targetUser, AdminRole);
|
||||
if (enabled && !inRole)
|
||||
{
|
||||
EnsureSucceeded(await _userManager.AddToRoleAsync(targetUser, AdminRole));
|
||||
}
|
||||
else if (!enabled && inRole)
|
||||
{
|
||||
EnsureSucceeded(await _userManager.RemoveFromRoleAsync(targetUser, AdminRole));
|
||||
}
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", actorUserId, "account.role_changed", new
|
||||
{
|
||||
target_user_id = targetUser.Id,
|
||||
email = targetUser.Email,
|
||||
role = AdminRole,
|
||||
enabled
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SetDisabledAsync(Guid actorUserId, Guid targetUserId, bool disabled)
|
||||
{
|
||||
await EnsureSuperuserAsync(actorUserId);
|
||||
if (actorUserId == targetUserId)
|
||||
{
|
||||
throw new InvalidOperationException("You cannot disable your own account.");
|
||||
}
|
||||
|
||||
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
|
||||
?? throw new InvalidOperationException("Target user not found.");
|
||||
await EnsureTargetIsMutableAsync(targetUser);
|
||||
|
||||
targetUser.DisabledAt = disabled ? DateTimeOffset.UtcNow : null;
|
||||
targetUser.DisabledBy = disabled ? actorUserId.ToString() : null;
|
||||
EnsureSucceeded(await _userManager.UpdateAsync(targetUser));
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", actorUserId, disabled ? "account.disabled" : "account.enabled", new
|
||||
{
|
||||
target_user_id = targetUser.Id,
|
||||
email = targetUser.Email
|
||||
});
|
||||
}
|
||||
|
||||
public async Task ResetPasswordAsync(Guid actorUserId, Guid targetUserId, string newPassword)
|
||||
{
|
||||
await EnsureSuperuserAsync(actorUserId);
|
||||
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
|
||||
?? throw new InvalidOperationException("Target user not found.");
|
||||
await EnsureTargetIsMutableAsync(targetUser);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newPassword))
|
||||
{
|
||||
throw new InvalidOperationException("New password is required.");
|
||||
}
|
||||
|
||||
var resetToken = await _userManager.GeneratePasswordResetTokenAsync(targetUser);
|
||||
EnsureSucceeded(await _userManager.ResetPasswordAsync(targetUser, resetToken, newPassword));
|
||||
EnsureSucceeded(await _userManager.UpdateSecurityStampAsync(targetUser));
|
||||
await RevokeUserAuthorizationsAsync(targetUser.Id);
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", actorUserId, "account.password_reset_by_superuser", new
|
||||
{
|
||||
target_user_id = targetUser.Id,
|
||||
email = targetUser.Email,
|
||||
revoke_existing_sessions = true
|
||||
});
|
||||
}
|
||||
|
||||
private async Task EnsureSuperuserAsync(Guid actorUserId)
|
||||
{
|
||||
await EnsureRoleExistsAsync(SuperuserRole);
|
||||
|
||||
var actor = await _userManager.FindByIdAsync(actorUserId.ToString())
|
||||
?? throw new InvalidOperationException("Actor user not found.");
|
||||
|
||||
if (!await _userManager.IsInRoleAsync(actor, SuperuserRole))
|
||||
{
|
||||
throw new InvalidOperationException("Only superuser can modify account governance.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureTargetIsMutableAsync(ApplicationUser targetUser)
|
||||
{
|
||||
if (await _userManager.IsInRoleAsync(targetUser, SuperuserRole))
|
||||
{
|
||||
throw new InvalidOperationException("Superuser accounts cannot be modified from the management UI.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureRoleExistsAsync(string roleName)
|
||||
{
|
||||
if (await _roleManager.RoleExistsAsync(roleName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureSucceeded(await _roleManager.CreateAsync(new ApplicationRole
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = roleName,
|
||||
NormalizedName = roleName.ToUpperInvariant()
|
||||
}));
|
||||
}
|
||||
|
||||
private static void EnsureSucceeded(IdentityResult result)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(string.Join("; ", result.Errors.Select(x => x.Description)));
|
||||
}
|
||||
|
||||
private async Task RevokeUserAuthorizationsAsync(Guid userId)
|
||||
{
|
||||
var subject = userId.ToString();
|
||||
|
||||
var tokens = await _dbContext.Set<OpenIddictEntityFrameworkCoreToken>()
|
||||
.Where(x => x.Subject == subject && x.Status != OpenIddictConstants.Statuses.Revoked)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
token.Status = OpenIddictConstants.Statuses.Revoked;
|
||||
token.RedemptionDate = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
var authorizations = await _dbContext.Set<OpenIddictEntityFrameworkCoreAuthorization>()
|
||||
.Where(x => x.Subject == subject && x.Status != OpenIddictConstants.Statuses.Revoked)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var authorization in authorizations)
|
||||
{
|
||||
authorization.Status = OpenIddictConstants.Statuses.Revoked;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@ -1,282 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Models.Account;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MemberCenter.Application.Models.Newsletter;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class AccountProvisioningService : IAccountProvisioningService
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly ISendEngineWebhookPublisher _webhookPublisher;
|
||||
private readonly IAuditLogWriter _auditLogWriter;
|
||||
private readonly ILogger<AccountProvisioningService> _logger;
|
||||
|
||||
public AccountProvisioningService(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
MemberCenterDbContext dbContext,
|
||||
ISendEngineWebhookPublisher webhookPublisher,
|
||||
IAuditLogWriter auditLogWriter,
|
||||
ILogger<AccountProvisioningService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_dbContext = dbContext;
|
||||
_webhookPublisher = webhookPublisher;
|
||||
_auditLogWriter = auditLogWriter;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AccountProvisioningResult> RegisterLocalAsync(string email, string password)
|
||||
{
|
||||
var normalizedEmail = NormalizeEmail(email);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = normalizedEmail,
|
||||
Email = normalizedEmail,
|
||||
EmailConfirmed = false
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return Failed(result);
|
||||
}
|
||||
|
||||
_dbContext.UserProfiles.Add(new UserProfile
|
||||
{
|
||||
UserId = user.Id,
|
||||
LastName = string.Empty,
|
||||
FirstName = string.Empty,
|
||||
Gender = "unspecified",
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(user, "local_registration", new
|
||||
{
|
||||
email = normalizedEmail
|
||||
});
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", user.Id, "account.registered", new
|
||||
{
|
||||
user_id = user.Id,
|
||||
email = normalizedEmail,
|
||||
registration_type = "local"
|
||||
});
|
||||
|
||||
return new AccountProvisioningResult(
|
||||
true,
|
||||
user.Id,
|
||||
user.Email,
|
||||
user.EmailConfirmed,
|
||||
true,
|
||||
false,
|
||||
linkedSubscriptionsCount,
|
||||
Array.Empty<string>());
|
||||
}
|
||||
|
||||
public async Task<AccountProvisioningResult> ProvisionExternalLoginAsync(
|
||||
string loginProvider,
|
||||
string providerKey,
|
||||
string? email,
|
||||
bool emailVerified)
|
||||
{
|
||||
var existingByLogin = await _userManager.FindByLoginAsync(loginProvider, providerKey);
|
||||
if (existingByLogin is not null)
|
||||
{
|
||||
var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(existingByLogin, "external_login_reuse", new
|
||||
{
|
||||
login_provider = loginProvider
|
||||
});
|
||||
|
||||
return new AccountProvisioningResult(
|
||||
true,
|
||||
existingByLogin.Id,
|
||||
existingByLogin.Email,
|
||||
existingByLogin.EmailConfirmed,
|
||||
false,
|
||||
false,
|
||||
linkedSubscriptionsCount,
|
||||
Array.Empty<string>());
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return new AccountProvisioningResult(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
new[] { "External login did not provide an email address." });
|
||||
}
|
||||
|
||||
var normalizedEmail = NormalizeEmail(email);
|
||||
var user = await _userManager.FindByEmailAsync(normalizedEmail);
|
||||
var createdUser = false;
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = normalizedEmail,
|
||||
Email = normalizedEmail,
|
||||
EmailConfirmed = emailVerified
|
||||
};
|
||||
|
||||
var createResult = await _userManager.CreateAsync(user);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
return Failed(createResult);
|
||||
}
|
||||
|
||||
_dbContext.UserProfiles.Add(new UserProfile
|
||||
{
|
||||
UserId = user.Id,
|
||||
LastName = string.Empty,
|
||||
FirstName = string.Empty,
|
||||
Gender = "unspecified",
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
createdUser = true;
|
||||
}
|
||||
else if (emailVerified && !user.EmailConfirmed)
|
||||
{
|
||||
user.EmailConfirmed = true;
|
||||
await _userManager.UpdateAsync(user);
|
||||
}
|
||||
|
||||
var addLoginResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(loginProvider, providerKey, loginProvider));
|
||||
if (!addLoginResult.Succeeded
|
||||
&& addLoginResult.Errors.All(x => x.Code != nameof(IdentityErrorDescriber.LoginAlreadyAssociated)))
|
||||
{
|
||||
return Failed(addLoginResult);
|
||||
}
|
||||
|
||||
var linkedSubscriptions = await LinkSubscriptionsAndAuditAsync(user, "external_login_linked", new
|
||||
{
|
||||
login_provider = loginProvider,
|
||||
created_user = createdUser
|
||||
});
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", user.Id, "account.external_login_linked", new
|
||||
{
|
||||
user_id = user.Id,
|
||||
email = user.Email,
|
||||
login_provider = loginProvider,
|
||||
created_user = createdUser
|
||||
});
|
||||
|
||||
return new AccountProvisioningResult(
|
||||
true,
|
||||
user.Id,
|
||||
user.Email,
|
||||
user.EmailConfirmed,
|
||||
createdUser,
|
||||
true,
|
||||
linkedSubscriptions,
|
||||
Array.Empty<string>());
|
||||
}
|
||||
|
||||
private async Task<int> LinkSubscriptionsAndAuditAsync(ApplicationUser user, string source, object payload)
|
||||
{
|
||||
var email = NormalizeEmail(user.Email ?? user.UserName ?? string.Empty);
|
||||
var subscriptions = await _dbContext.NewsletterSubscriptions
|
||||
.Where(x => x.UserId == null && x.Email.ToLower() == email)
|
||||
.ToListAsync();
|
||||
|
||||
if (subscriptions.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var subscription in subscriptions)
|
||||
{
|
||||
subscription.UserId = user.Id;
|
||||
}
|
||||
|
||||
var linkedSubscriptions = subscriptions
|
||||
.Select(subscription => new SubscriptionDto(
|
||||
subscription.Id,
|
||||
subscription.ListId,
|
||||
subscription.Email,
|
||||
subscription.Status,
|
||||
subscription.Preferences.RootElement.Clone(),
|
||||
subscription.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
_dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ActorType = "system",
|
||||
ActorId = user.Id,
|
||||
Action = "subscription.linked_to_user",
|
||||
Payload = JsonDocument.Parse(JsonSerializer.Serialize(new
|
||||
{
|
||||
user_id = user.Id,
|
||||
email,
|
||||
linked_subscriptions = subscriptions.Count,
|
||||
source,
|
||||
metadata = payload
|
||||
}))
|
||||
});
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
await PublishLinkedSubscriptionEventsAsync(linkedSubscriptions);
|
||||
return subscriptions.Count;
|
||||
}
|
||||
|
||||
private async Task PublishLinkedSubscriptionEventsAsync(IReadOnlyList<SubscriptionDto> subscriptions)
|
||||
{
|
||||
if (subscriptions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var listIds = subscriptions.Select(x => x.ListId).Distinct().ToList();
|
||||
var tenantMap = await _dbContext.NewsletterLists
|
||||
.Where(x => listIds.Contains(x.Id))
|
||||
.ToDictionaryAsync(x => x.Id, x => x.TenantId);
|
||||
|
||||
foreach (var subscription in subscriptions)
|
||||
{
|
||||
if (!tenantMap.TryGetValue(subscription.ListId, out var tenantId) || tenantId == Guid.Empty)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skip linked subscription event because list {ListId} has no tenant mapping",
|
||||
subscription.ListId);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _webhookPublisher.PublishSubscriptionEventAsync("subscription.linked_to_user", tenantId, subscription);
|
||||
}
|
||||
}
|
||||
|
||||
private static AccountProvisioningResult Failed(IdentityResult result) =>
|
||||
new(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
result.Errors.Select(x => x.Description).ToArray());
|
||||
|
||||
private static string NormalizeEmail(string email) =>
|
||||
email.Trim().ToLowerInvariant();
|
||||
}
|
||||
@ -22,7 +22,7 @@ public sealed class AuditLogService : IAuditLogService
|
||||
.ToListAsync();
|
||||
|
||||
return logs
|
||||
.Select(l => new AuditLogDto(l.Id, l.ActorType, l.ActorId, l.Action, l.Payload.RootElement.GetRawText(), l.CreatedAt))
|
||||
.Select(l => new AuditLogDto(l.Id, l.ActorType, l.ActorId, l.Action, l.CreatedAt))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class AuditLogWriter : IAuditLogWriter
|
||||
{
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
|
||||
public AuditLogWriter(MemberCenterDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task WriteAsync(string actorType, Guid? actorId, string action, object payload)
|
||||
{
|
||||
_dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ActorType = actorType,
|
||||
ActorId = actorId,
|
||||
Action = action,
|
||||
Payload = JsonDocument.Parse(JsonSerializer.Serialize(payload))
|
||||
});
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@ -1,293 +0,0 @@
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class AuthResourceRegistryService : IAuthResourceRegistryService
|
||||
{
|
||||
private const string MemberCenterResourceName = "member_center_api";
|
||||
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AuthResourceRegistryService(MemberCenterDbContext dbContext, IConfiguration configuration)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task EnsureDefaultsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var memberCenter = await EnsureResourceAsync(
|
||||
MemberCenterResourceName,
|
||||
ResolveAudience("MemberCenter", "Auth:MemberCenterAudience", "member_center_api"),
|
||||
"Member Center API",
|
||||
requireTenant: false,
|
||||
allowDelegatedToken: true,
|
||||
cancellationToken);
|
||||
|
||||
var sendEngine = await EnsureResourceAsync(
|
||||
"send_engine_api",
|
||||
ResolveAudience("SendEngine", "Auth:SendEngineAudience", "send_engine_api"),
|
||||
"Send Engine API",
|
||||
requireTenant: true,
|
||||
allowDelegatedToken: false,
|
||||
cancellationToken);
|
||||
|
||||
var fileAccess = await EnsureResourceAsync(
|
||||
"file_access_api",
|
||||
ResolveAudience("FileAccess", null, "file_access_api"),
|
||||
"File upload/download API",
|
||||
requireTenant: true,
|
||||
allowDelegatedToken: true,
|
||||
cancellationToken);
|
||||
|
||||
await EnsureScopesAsync(memberCenter.Id, [
|
||||
OpenIddictConstants.Scopes.OpenId,
|
||||
OpenIddictConstants.Scopes.Email,
|
||||
OpenIddictConstants.Scopes.Profile,
|
||||
"profile:basic.read",
|
||||
"profile:basic.write",
|
||||
"profile:addresses.read",
|
||||
"profile:addresses.write",
|
||||
"profile:subscriptions.read",
|
||||
"profile:subscriptions.write",
|
||||
"newsletter:list.read",
|
||||
"newsletter:events.read",
|
||||
"newsletter:events.write",
|
||||
"newsletter:events.write.global"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureScopesAsync(sendEngine.Id, [
|
||||
"newsletter:send.write",
|
||||
"newsletter:send.read"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureScopesAsync(fileAccess.Id, [
|
||||
"files:upload.write",
|
||||
"files:download.read",
|
||||
"files:download.delegate",
|
||||
"files:metadata.read",
|
||||
"files:delete"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("web_login", [
|
||||
OpenIddictConstants.Scopes.OpenId,
|
||||
OpenIddictConstants.Scopes.Email,
|
||||
OpenIddictConstants.Scopes.Profile,
|
||||
"profile:basic.read",
|
||||
"profile:basic.write",
|
||||
"profile:addresses.read",
|
||||
"profile:addresses.write",
|
||||
"profile:subscriptions.read",
|
||||
"profile:subscriptions.write"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("webhook_outbound", [
|
||||
OpenIddictConstants.Scopes.OpenId,
|
||||
OpenIddictConstants.Scopes.Email,
|
||||
OpenIddictConstants.Scopes.Profile,
|
||||
"newsletter:events.write"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("tenant_api", [
|
||||
"newsletter:events.write",
|
||||
"newsletter:list.read",
|
||||
"profile:basic.read",
|
||||
"profile:basic.write",
|
||||
"profile:addresses.read",
|
||||
"profile:addresses.write",
|
||||
"profile:subscriptions.read",
|
||||
"profile:subscriptions.write"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("platform_service", [
|
||||
"newsletter:events.write.global",
|
||||
"newsletter:list.read",
|
||||
"profile:basic.read",
|
||||
"profile:basic.write",
|
||||
"profile:addresses.read",
|
||||
"profile:addresses.write",
|
||||
"profile:subscriptions.read",
|
||||
"profile:subscriptions.write"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("send_api", [
|
||||
"newsletter:send.write",
|
||||
"newsletter:send.read"
|
||||
], cancellationToken);
|
||||
|
||||
await EnsureUsagePermissionsAsync("file_api", [
|
||||
"files:upload.write",
|
||||
"files:download.read",
|
||||
"files:download.delegate",
|
||||
"files:metadata.read",
|
||||
"files:delete"
|
||||
], cancellationToken);
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> ResolveAudiencesAsync(
|
||||
IEnumerable<string> scopes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestedScopes = scopes
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (requestedScopes.Count == 0)
|
||||
{
|
||||
var defaultAudience = await _dbContext.AuthResources
|
||||
.Where(resource => resource.Name == MemberCenterResourceName && resource.IsEnabled)
|
||||
.Select(resource => resource.Audience)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return string.IsNullOrWhiteSpace(defaultAudience)
|
||||
? ["member_center_api"]
|
||||
: [defaultAudience];
|
||||
}
|
||||
|
||||
var resourceScopes = await _dbContext.AuthResourceScopes
|
||||
.AsNoTracking()
|
||||
.Include(resourceScope => resourceScope.Resource)
|
||||
.Where(resourceScope =>
|
||||
resourceScope.IsEnabled
|
||||
&& requestedScopes.Contains(resourceScope.Scope)
|
||||
&& resourceScope.Resource != null
|
||||
&& resourceScope.Resource.IsEnabled)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var resolvedScopes = resourceScopes
|
||||
.Select(resourceScope => resourceScope.Scope)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var unknownScopes = requestedScopes
|
||||
.Where(scope => !resolvedScopes.Contains(scope))
|
||||
.ToList();
|
||||
if (unknownScopes.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown auth scope(s): {string.Join(", ", unknownScopes)}");
|
||||
}
|
||||
|
||||
return resourceScopes
|
||||
.Select(resourceScope => resourceScope.Resource!.Audience)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetAllowedScopesForUsageAsync(
|
||||
string usage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedUsage = usage.Trim();
|
||||
return await _dbContext.AuthClientUsagePermissions
|
||||
.AsNoTracking()
|
||||
.Where(permission => permission.Usage == normalizedUsage && permission.IsEnabled)
|
||||
.OrderBy(permission => permission.Scope)
|
||||
.Select(permission => permission.Scope)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthResource> EnsureResourceAsync(
|
||||
string name,
|
||||
string audience,
|
||||
string description,
|
||||
bool requireTenant,
|
||||
bool allowDelegatedToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resource = await _dbContext.AuthResources
|
||||
.SingleOrDefaultAsync(item => item.Name == name, cancellationToken);
|
||||
|
||||
if (resource is null)
|
||||
{
|
||||
resource = new AuthResource
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Audience = audience
|
||||
};
|
||||
_dbContext.AuthResources.Add(resource);
|
||||
}
|
||||
|
||||
resource.Audience = audience;
|
||||
resource.Description = description;
|
||||
resource.RequireTenant = requireTenant;
|
||||
resource.AllowDelegatedToken = allowDelegatedToken;
|
||||
resource.IsEnabled = true;
|
||||
resource.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
return resource;
|
||||
}
|
||||
|
||||
private string ResolveAudience(string resourceKey, string? legacyKey, string defaultValue)
|
||||
{
|
||||
var configured = _configuration[$"Auth:Resources:{resourceKey}:Audience"];
|
||||
if (string.IsNullOrWhiteSpace(configured) && !string.IsNullOrWhiteSpace(legacyKey))
|
||||
{
|
||||
configured = _configuration[legacyKey];
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(configured) ? defaultValue : configured;
|
||||
}
|
||||
|
||||
private async Task EnsureScopesAsync(
|
||||
Guid resourceId,
|
||||
IEnumerable<string> scopes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingScopes = await _dbContext.AuthResourceScopes
|
||||
.Where(scope => scope.ResourceId == resourceId)
|
||||
.ToDictionaryAsync(scope => scope.Scope, StringComparer.Ordinal, cancellationToken);
|
||||
|
||||
foreach (var scope in scopes.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (existingScopes.TryGetValue(scope, out var existing))
|
||||
{
|
||||
existing.IsEnabled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
_dbContext.AuthResourceScopes.Add(new AuthResourceScope
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ResourceId = resourceId,
|
||||
Scope = scope,
|
||||
IsEnabled = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureUsagePermissionsAsync(
|
||||
string usage,
|
||||
IEnumerable<string> scopes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingPermissions = await _dbContext.AuthClientUsagePermissions
|
||||
.Where(permission => permission.Usage == usage)
|
||||
.ToDictionaryAsync(permission => permission.Scope, StringComparer.Ordinal, cancellationToken);
|
||||
|
||||
foreach (var scope in scopes.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (existingPermissions.TryGetValue(scope, out var existing))
|
||||
{
|
||||
existing.IsEnabled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
_dbContext.AuthClientUsagePermissions.Add(new AuthClientUsagePermission
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Usage = usage,
|
||||
Scope = scope,
|
||||
IsEnabled = true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Models.Newsletter;
|
||||
using MemberCenter.Application.Models.Profile;
|
||||
using MemberCenter.Domain.Constants;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
@ -359,122 +358,6 @@ public sealed class NewsletterService : INewsletterService
|
||||
return subscriptions.Select(MapSubscription).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsForUserAsync(Guid userId)
|
||||
{
|
||||
return await (
|
||||
from subscription in _dbContext.NewsletterSubscriptions
|
||||
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
|
||||
join tenant in _dbContext.Tenants on list.TenantId equals tenant.Id
|
||||
where subscription.UserId == userId
|
||||
orderby tenant.Name, list.Name
|
||||
select new UserSubscriptionSummaryDto(
|
||||
subscription.Id,
|
||||
subscription.ListId,
|
||||
tenant.Id,
|
||||
tenant.Name,
|
||||
list.Name,
|
||||
subscription.Email,
|
||||
subscription.Status,
|
||||
subscription.CreatedAt))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<UserSubscriptionSummaryDto?> UnsubscribeForUserAsync(Guid userId, Guid subscriptionId)
|
||||
{
|
||||
var subscription = await _dbContext.NewsletterSubscriptions
|
||||
.FirstOrDefaultAsync(x => x.Id == subscriptionId && x.UserId == userId);
|
||||
if (subscription is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(subscription.Email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
subscription.Status = SubscriptionStatus.Unsubscribed;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var updated = await (
|
||||
from saved in _dbContext.NewsletterSubscriptions
|
||||
join list in _dbContext.NewsletterLists on saved.ListId equals list.Id
|
||||
join tenant in _dbContext.Tenants on list.TenantId equals tenant.Id
|
||||
where saved.Id == subscriptionId
|
||||
select new UserSubscriptionSummaryDto(
|
||||
saved.Id,
|
||||
saved.ListId,
|
||||
tenant.Id,
|
||||
tenant.Name,
|
||||
list.Name,
|
||||
saved.Email,
|
||||
saved.Status,
|
||||
saved.CreatedAt))
|
||||
.FirstAsync();
|
||||
|
||||
await PublishSubscriptionEventSafeAsync("subscription.unsubscribed", MapSubscription(subscription));
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsByEmailAsync(string email)
|
||||
{
|
||||
var normalizedEmail = email.Trim().ToLowerInvariant();
|
||||
return await (
|
||||
from subscription in _dbContext.NewsletterSubscriptions
|
||||
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
|
||||
join tenant in _dbContext.Tenants on list.TenantId equals tenant.Id
|
||||
where subscription.Email.ToLower() == normalizedEmail
|
||||
orderby tenant.Name, list.Name
|
||||
select new UserSubscriptionSummaryDto(
|
||||
subscription.Id,
|
||||
subscription.ListId,
|
||||
tenant.Id,
|
||||
tenant.Name,
|
||||
list.Name,
|
||||
subscription.Email,
|
||||
subscription.Status,
|
||||
subscription.CreatedAt))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<UserSubscriptionSummaryDto?> UnsubscribeByEmailAsync(string email, Guid subscriptionId)
|
||||
{
|
||||
var normalizedEmail = email.Trim().ToLowerInvariant();
|
||||
var subscription = await _dbContext.NewsletterSubscriptions
|
||||
.FirstOrDefaultAsync(x => x.Id == subscriptionId && x.Email.ToLower() == normalizedEmail);
|
||||
if (subscription is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await _emailBlacklist.IsBlacklistedAsync(subscription.Email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
subscription.Status = SubscriptionStatus.Unsubscribed;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var updated = await (
|
||||
from saved in _dbContext.NewsletterSubscriptions
|
||||
join list in _dbContext.NewsletterLists on saved.ListId equals list.Id
|
||||
join tenant in _dbContext.Tenants on list.TenantId equals tenant.Id
|
||||
where saved.Id == subscriptionId
|
||||
select new UserSubscriptionSummaryDto(
|
||||
saved.Id,
|
||||
saved.ListId,
|
||||
tenant.Id,
|
||||
tenant.Name,
|
||||
list.Name,
|
||||
saved.Email,
|
||||
saved.Status,
|
||||
saved.CreatedAt))
|
||||
.FirstAsync();
|
||||
|
||||
await PublishSubscriptionEventSafeAsync("subscription.unsubscribed", MapSubscription(subscription));
|
||||
return updated;
|
||||
}
|
||||
|
||||
private static string CreateToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
@ -1,370 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Models.Profile;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class ProfileService : IProfileService
|
||||
{
|
||||
private static readonly HashSet<string> AllowedGenders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"male",
|
||||
"female",
|
||||
"other",
|
||||
"unspecified"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AllowedAddressUsages = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"shipping",
|
||||
"billing",
|
||||
"both"
|
||||
};
|
||||
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly IAuditLogWriter _auditLogWriter;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public ProfileService(MemberCenterDbContext dbContext, IAuditLogWriter auditLogWriter, UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_auditLogWriter = auditLogWriter;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto> GetProfileAsync(Guid userId)
|
||||
{
|
||||
var user = await _userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
var profile = await EnsureProfileAsync(userId);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return MapProfile(profile, user.Email ?? string.Empty);
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto> SaveProfileAsync(Guid userId, SaveUserProfileRequest request)
|
||||
{
|
||||
var user = await _userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null)
|
||||
{
|
||||
throw new InvalidOperationException("User not found.");
|
||||
}
|
||||
|
||||
var profile = await EnsureProfileAsync(userId);
|
||||
profile.LastName = RequireValue(request.LastName, "LastName", 100);
|
||||
profile.FirstName = RequireValue(request.FirstName, "FirstName", 100);
|
||||
profile.NickName = CleanValue(request.NickName, 100);
|
||||
profile.MobilePhone = CleanValue(request.MobilePhone, 50);
|
||||
profile.LandlinePhone = CleanValue(request.LandlinePhone, 50);
|
||||
profile.DateOfBirth = request.DateOfBirth;
|
||||
profile.Gender = NormalizeGender(request.Gender);
|
||||
profile.CompanyName = CleanValue(request.CompanyName, 200);
|
||||
profile.Department = CleanValue(request.Department, 200);
|
||||
profile.JobTitle = CleanValue(request.JobTitle, 200);
|
||||
profile.CompanyPhone = CleanValue(request.CompanyPhone, 50);
|
||||
profile.TaxId = CleanValue(request.TaxId, 32);
|
||||
profile.InvoiceTitle = CleanValue(request.InvoiceTitle, 200);
|
||||
profile.Remark = CleanValue(request.Remark, 1000);
|
||||
profile.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", userId, "profile.updated", new
|
||||
{
|
||||
user_id = userId,
|
||||
email = user.Email,
|
||||
profile.Gender
|
||||
});
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return MapProfile(profile, user.Email ?? string.Empty);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserAddressDto>> ListAddressesAsync(Guid userId)
|
||||
{
|
||||
var addresses = await _dbContext.UserAddresses
|
||||
.Where(x => x.UserId == userId)
|
||||
.OrderByDescending(x => x.IsDefault)
|
||||
.ThenBy(x => x.Label)
|
||||
.ToListAsync();
|
||||
|
||||
return addresses.Select(MapAddress).ToList();
|
||||
}
|
||||
|
||||
public async Task<UserAddressDto?> GetAddressAsync(Guid userId, Guid addressId)
|
||||
{
|
||||
var address = await _dbContext.UserAddresses
|
||||
.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == addressId);
|
||||
|
||||
return address is null ? null : MapAddress(address);
|
||||
}
|
||||
|
||||
public async Task<UserAddressDto> SaveAddressAsync(Guid userId, SaveUserAddressRequest request)
|
||||
{
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
|
||||
|
||||
var usage = NormalizeUsage(request.Usage);
|
||||
var address = request.Id.HasValue
|
||||
? await _dbContext.UserAddresses.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == request.Id.Value)
|
||||
: null;
|
||||
var wasDefault = address?.IsDefault == true;
|
||||
|
||||
if (request.Id.HasValue && address is null)
|
||||
{
|
||||
throw new InvalidOperationException("Address not found.");
|
||||
}
|
||||
|
||||
if (address is null)
|
||||
{
|
||||
address = new UserAddress
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_dbContext.UserAddresses.Add(address);
|
||||
}
|
||||
|
||||
address.Label = RequireValue(request.Label, "Label", 100);
|
||||
address.RecipientName = RequireValue(request.RecipientName, "RecipientName", 100);
|
||||
address.RecipientPhone = RequireValue(request.RecipientPhone, "RecipientPhone", 50);
|
||||
address.CountryCode = RequireCountryCode(request.CountryCode);
|
||||
address.PostalCode = CleanValue(request.PostalCode, 20);
|
||||
address.StateRegion = CleanValue(request.StateRegion, 100);
|
||||
address.City = CleanValue(request.City, 100);
|
||||
address.District = CleanValue(request.District, 100);
|
||||
address.AddressLine1 = RequireValue(request.AddressLine1, "AddressLine1", 255);
|
||||
address.AddressLine2 = CleanValue(request.AddressLine2, 255);
|
||||
address.CompanyName = CleanValue(request.CompanyName, 200);
|
||||
address.Usage = usage;
|
||||
address.AddressMetaJson = ParseOptionalJson(request.AddressMetaJson);
|
||||
address.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var otherAddresses = await _dbContext.UserAddresses
|
||||
.Where(x => x.UserId == userId && x.Id != address.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var shouldBeDefault = !otherAddresses.Any()
|
||||
|| request.IsDefault
|
||||
|| (!otherAddresses.Any(x => x.IsDefault) && wasDefault)
|
||||
|| (!otherAddresses.Any(x => x.IsDefault) && !request.Id.HasValue);
|
||||
|
||||
// Persist the address first with a non-default state so the unique index
|
||||
// never sees two defaults during the switch.
|
||||
address.IsDefault = false;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
if (shouldBeDefault)
|
||||
{
|
||||
await _dbContext.UserAddresses
|
||||
.Where(x => x.UserId == userId && x.Id != address.Id && x.IsDefault)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.IsDefault, false)
|
||||
.SetProperty(x => x.UpdatedAt, DateTimeOffset.UtcNow));
|
||||
|
||||
address.IsDefault = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
address.IsDefault = false;
|
||||
}
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", userId, request.Id.HasValue ? "address.updated" : "address.created", new
|
||||
{
|
||||
user_id = userId,
|
||||
address_id = address.Id,
|
||||
address.Usage,
|
||||
address.IsDefault
|
||||
});
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
return MapAddress(address);
|
||||
}
|
||||
|
||||
public async Task DeleteAddressAsync(Guid userId, Guid addressId)
|
||||
{
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
|
||||
|
||||
var addresses = await _dbContext.UserAddresses
|
||||
.Where(x => x.UserId == userId)
|
||||
.OrderByDescending(x => x.IsDefault)
|
||||
.ThenBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var address = addresses.FirstOrDefault(x => x.Id == addressId);
|
||||
if (address is null)
|
||||
{
|
||||
throw new InvalidOperationException("Address not found.");
|
||||
}
|
||||
|
||||
if (addresses.Count == 1)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot delete the last address.");
|
||||
}
|
||||
|
||||
if (address.IsDefault)
|
||||
{
|
||||
var replacement = addresses
|
||||
.Where(x => x.Id != addressId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (replacement is not null)
|
||||
{
|
||||
_dbContext.UserAddresses.Remove(address);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
replacement.IsDefault = true;
|
||||
replacement.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
_dbContext.UserAddresses.Remove(address);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_dbContext.UserAddresses.Remove(address);
|
||||
}
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", userId, "address.deleted", new
|
||||
{
|
||||
user_id = userId,
|
||||
address_id = addressId
|
||||
});
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
private async Task<UserProfile> EnsureProfileAsync(Guid userId)
|
||||
{
|
||||
var profile = await _dbContext.UserProfiles.FirstOrDefaultAsync(x => x.UserId == userId);
|
||||
if (profile is not null)
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
profile = new UserProfile
|
||||
{
|
||||
UserId = userId,
|
||||
LastName = string.Empty,
|
||||
FirstName = string.Empty,
|
||||
Gender = "unspecified",
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_dbContext.UserProfiles.Add(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static UserProfileDto MapProfile(UserProfile profile, string email) =>
|
||||
new(
|
||||
profile.UserId,
|
||||
email,
|
||||
profile.LastName,
|
||||
profile.FirstName,
|
||||
profile.NickName,
|
||||
profile.MobilePhone,
|
||||
profile.LandlinePhone,
|
||||
profile.DateOfBirth,
|
||||
profile.Gender,
|
||||
profile.CompanyName,
|
||||
profile.Department,
|
||||
profile.JobTitle,
|
||||
profile.CompanyPhone,
|
||||
profile.TaxId,
|
||||
profile.InvoiceTitle,
|
||||
profile.Remark);
|
||||
|
||||
private static UserAddressDto MapAddress(UserAddress address) =>
|
||||
new(
|
||||
address.Id,
|
||||
address.UserId,
|
||||
address.Label,
|
||||
address.RecipientName,
|
||||
address.RecipientPhone,
|
||||
address.CountryCode,
|
||||
address.PostalCode,
|
||||
address.StateRegion,
|
||||
address.City,
|
||||
address.District,
|
||||
address.AddressLine1,
|
||||
address.AddressLine2,
|
||||
address.CompanyName,
|
||||
address.Usage,
|
||||
address.IsDefault,
|
||||
address.AddressMetaJson?.RootElement.GetRawText());
|
||||
|
||||
private static string RequireValue(string? value, string fieldName, int maxLength)
|
||||
{
|
||||
var cleaned = CleanValue(value, maxLength);
|
||||
if (string.IsNullOrWhiteSpace(cleaned))
|
||||
{
|
||||
throw new InvalidOperationException($"{fieldName} is required.");
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static string? CleanValue(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length > maxLength)
|
||||
{
|
||||
throw new InvalidOperationException($"Value exceeds max length {maxLength}.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string NormalizeGender(string? value)
|
||||
{
|
||||
var normalized = CleanValue(value, 20)?.ToLowerInvariant() ?? "unspecified";
|
||||
if (!AllowedGenders.Contains(normalized))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid gender.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeUsage(string? value)
|
||||
{
|
||||
var normalized = RequireValue(value, "Usage", 20).ToLowerInvariant();
|
||||
if (!AllowedAddressUsages.Contains(normalized))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid address usage.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RequireCountryCode(string? value)
|
||||
{
|
||||
var normalized = RequireValue(value, "CountryCode", 2).ToUpperInvariant();
|
||||
if (normalized.Length != 2)
|
||||
{
|
||||
throw new InvalidOperationException("CountryCode must be 2 characters.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static JsonDocument? ParseOptionalJson(string? raw)
|
||||
{
|
||||
var cleaned = CleanValue(raw, 4000);
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? null : JsonDocument.Parse(cleaned);
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,6 @@ using MemberCenter.Application.Models.Admin;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
@ -12,177 +10,28 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
|
||||
{
|
||||
private const string AccessTokenKey = "token_access_minutes";
|
||||
private const string RefreshTokenKey = "token_refresh_days";
|
||||
private const string PublicBaseUrlKey = "public_base_url";
|
||||
private const string SmtpRelayHostKey = "smtp_relay_host";
|
||||
private const string SmtpRelayPortKey = "smtp_relay_port";
|
||||
private const string SmtpUseTlsKey = "smtp_use_tls";
|
||||
private const string SmtpUseSslKey = "smtp_use_ssl";
|
||||
private const string SmtpTimeoutSecondsKey = "smtp_timeout_seconds";
|
||||
private const string SmtpUsernameKey = "smtp_username";
|
||||
private const string SmtpPasswordKey = "smtp_password";
|
||||
private const string SenderNameKey = "smtp_sender_name";
|
||||
private const string SenderEmailKey = "smtp_sender_email";
|
||||
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly IAuditLogWriter _auditLogWriter;
|
||||
|
||||
public SecuritySettingsService(
|
||||
MemberCenterDbContext dbContext,
|
||||
IAuditLogWriter auditLogWriter)
|
||||
public SecuritySettingsService(MemberCenterDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_auditLogWriter = auditLogWriter;
|
||||
}
|
||||
|
||||
public async Task<SecuritySettingsDto> GetAsync()
|
||||
{
|
||||
var access = await GetFlagAsync(AccessTokenKey, 60);
|
||||
var refresh = await GetFlagAsync(RefreshTokenKey, 30);
|
||||
var smtp = await GetSmtpSettingsAsync();
|
||||
return new SecuritySettingsDto(
|
||||
access,
|
||||
refresh,
|
||||
smtp.PublicBaseUrl,
|
||||
smtp.RelayHost,
|
||||
smtp.RelayPort,
|
||||
smtp.UseTls,
|
||||
smtp.UseSsl,
|
||||
smtp.TimeoutSeconds,
|
||||
smtp.Username,
|
||||
string.Empty,
|
||||
smtp.HasPassword,
|
||||
smtp.SenderName,
|
||||
smtp.SenderEmail,
|
||||
string.Empty);
|
||||
return new SecuritySettingsDto(access, refresh);
|
||||
}
|
||||
|
||||
public async Task<SmtpSettingsDto> GetSmtpSettingsAsync()
|
||||
public async Task SaveAsync(SecuritySettingsDto settings)
|
||||
{
|
||||
var relayHost = await GetFlagAsync(SmtpRelayHostKey, string.Empty);
|
||||
var publicBaseUrl = await GetFlagAsync(PublicBaseUrlKey, string.Empty);
|
||||
var relayPort = await GetFlagAsync(SmtpRelayPortKey, 587);
|
||||
var useTls = await GetFlagAsync(SmtpUseTlsKey, true);
|
||||
var useSsl = await GetFlagAsync(SmtpUseSslKey, false);
|
||||
var timeoutSeconds = await GetFlagAsync(SmtpTimeoutSecondsKey, 15);
|
||||
var username = await GetFlagAsync(SmtpUsernameKey, string.Empty);
|
||||
var password = await GetFlagAsync(SmtpPasswordKey, string.Empty);
|
||||
var senderName = await GetFlagAsync(SenderNameKey, "Member Center");
|
||||
var senderEmail = await GetFlagAsync(SenderEmailKey, string.Empty);
|
||||
return new SmtpSettingsDto(
|
||||
publicBaseUrl,
|
||||
relayHost,
|
||||
relayPort,
|
||||
useTls,
|
||||
useSsl,
|
||||
timeoutSeconds,
|
||||
username,
|
||||
password,
|
||||
!string.IsNullOrWhiteSpace(password),
|
||||
senderName,
|
||||
senderEmail);
|
||||
}
|
||||
|
||||
public async Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null)
|
||||
{
|
||||
var publicBaseUrl = Normalize(settings.PublicBaseUrl);
|
||||
var relayHost = Normalize(settings.SmtpRelayHost);
|
||||
var username = Normalize(settings.SmtpUsername);
|
||||
var senderName = Normalize(settings.SenderName);
|
||||
var senderEmail = Normalize(settings.SenderEmail);
|
||||
|
||||
if (settings.SmtpUseTls && settings.SmtpUseSsl)
|
||||
{
|
||||
throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled.");
|
||||
}
|
||||
|
||||
await SetFlagAsync(AccessTokenKey, settings.AccessTokenMinutes.ToString());
|
||||
await SetFlagAsync(RefreshTokenKey, settings.RefreshTokenDays.ToString());
|
||||
await SetFlagAsync(PublicBaseUrlKey, publicBaseUrl);
|
||||
await SetFlagAsync(SmtpRelayHostKey, relayHost);
|
||||
await SetFlagAsync(SmtpRelayPortKey, settings.SmtpRelayPort.ToString());
|
||||
await SetFlagAsync(SmtpUseTlsKey, settings.SmtpUseTls.ToString());
|
||||
await SetFlagAsync(SmtpUseSslKey, settings.SmtpUseSsl.ToString());
|
||||
await SetFlagAsync(SmtpTimeoutSecondsKey, settings.SmtpTimeoutSeconds.ToString());
|
||||
await SetFlagAsync(SmtpUsernameKey, username);
|
||||
if (!string.IsNullOrWhiteSpace(settings.SmtpPassword))
|
||||
{
|
||||
await SetFlagAsync(SmtpPasswordKey, settings.SmtpPassword);
|
||||
}
|
||||
await SetFlagAsync(SenderNameKey, senderName);
|
||||
await SetFlagAsync(SenderEmailKey, senderEmail);
|
||||
|
||||
await _auditLogWriter.WriteAsync("user", actorUserId, "system.security_settings_updated", new
|
||||
{
|
||||
settings.AccessTokenMinutes,
|
||||
settings.RefreshTokenDays,
|
||||
PublicBaseUrl = publicBaseUrl,
|
||||
SmtpRelayHost = relayHost,
|
||||
settings.SmtpRelayPort,
|
||||
settings.SmtpUseTls,
|
||||
settings.SmtpUseSsl,
|
||||
settings.SmtpTimeoutSeconds,
|
||||
SmtpUsername = username,
|
||||
SenderName = senderName,
|
||||
SenderEmail = senderEmail
|
||||
});
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<int> SendTestEmailAsync(string toEmail, Guid? actorUserId = null)
|
||||
{
|
||||
var smtp = await GetSmtpSettingsAsync();
|
||||
ValidateSmtpSettings(smtp);
|
||||
|
||||
using var message = new MailMessage
|
||||
{
|
||||
Subject = "[SMTP Test] Member Center SMTP 設定測試",
|
||||
Body = "這是一封測試信,代表 SMTP 設定可正常寄送。",
|
||||
IsBodyHtml = false,
|
||||
From = new MailAddress(smtp.SenderEmail, smtp.SenderName)
|
||||
};
|
||||
message.To.Add(new MailAddress(toEmail));
|
||||
message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString("<p>這是一封測試信,代表 SMTP 設定可正常寄送。</p>", null, "text/html"));
|
||||
|
||||
using var client = new SmtpClient(smtp.RelayHost, smtp.RelayPort)
|
||||
{
|
||||
EnableSsl = smtp.UseSsl || smtp.UseTls,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network,
|
||||
Timeout = Math.Max(1000, smtp.TimeoutSeconds * 1000)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(smtp.Username))
|
||||
{
|
||||
client.Credentials = new NetworkCredential(smtp.Username, smtp.Password);
|
||||
}
|
||||
|
||||
await client.SendMailAsync(message);
|
||||
await _auditLogWriter.WriteAsync("user", actorUserId, "system.security_test_email_sent", new
|
||||
{
|
||||
to_email = toEmail,
|
||||
sender_email = smtp.SenderEmail
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void ValidateSmtpSettings(SmtpSettingsDto smtp)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(smtp.RelayHost))
|
||||
{
|
||||
throw new InvalidOperationException("SMTP relay host is empty. Please save SMTP settings first.");
|
||||
}
|
||||
|
||||
if (smtp.UseTls && smtp.UseSsl)
|
||||
{
|
||||
throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(smtp.SenderEmail))
|
||||
{
|
||||
throw new InvalidOperationException("Sender email is empty. Please save sender settings first.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> GetFlagAsync(string key, int defaultValue)
|
||||
{
|
||||
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == key);
|
||||
@ -194,23 +43,6 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
|
||||
return int.TryParse(flag.Value, out var value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
private async Task<bool> GetFlagAsync(string key, bool defaultValue)
|
||||
{
|
||||
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == key);
|
||||
if (flag is null)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return bool.TryParse(flag.Value, out var value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
private async Task<string> GetFlagAsync(string key, string defaultValue)
|
||||
{
|
||||
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == key);
|
||||
return flag?.Value ?? defaultValue;
|
||||
}
|
||||
|
||||
private async Task SetFlagAsync(string key, string value)
|
||||
{
|
||||
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == key);
|
||||
@ -230,7 +62,4 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
|
||||
flag.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalize(string? value) => value?.Trim() ?? string.Empty;
|
||||
|
||||
}
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Application.Models.Admin;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Services;
|
||||
|
||||
public sealed class SmtpEmailSender : IEmailSender
|
||||
{
|
||||
private readonly ISecuritySettingsService _securitySettingsService;
|
||||
|
||||
public SmtpEmailSender(ISecuritySettingsService securitySettingsService)
|
||||
{
|
||||
_securitySettingsService = securitySettingsService;
|
||||
}
|
||||
|
||||
public async Task<int> SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null)
|
||||
{
|
||||
var settings = await _securitySettingsService.GetSmtpSettingsAsync();
|
||||
Validate(settings);
|
||||
|
||||
using var message = new MailMessage
|
||||
{
|
||||
Subject = subject,
|
||||
Body = htmlBody ?? textBody,
|
||||
IsBodyHtml = !string.IsNullOrWhiteSpace(htmlBody),
|
||||
From = new MailAddress(settings.SenderEmail, settings.SenderName)
|
||||
};
|
||||
message.To.Add(new MailAddress(toEmail));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(textBody) && !string.IsNullOrWhiteSpace(htmlBody))
|
||||
{
|
||||
message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(textBody, null, "text/plain"));
|
||||
message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(htmlBody, null, "text/html"));
|
||||
message.Body = textBody;
|
||||
message.IsBodyHtml = false;
|
||||
}
|
||||
|
||||
using var client = new SmtpClient(settings.RelayHost, settings.RelayPort)
|
||||
{
|
||||
EnableSsl = settings.UseSsl || settings.UseTls,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network,
|
||||
Timeout = Math.Max(1000, settings.TimeoutSeconds * 1000)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Username))
|
||||
{
|
||||
client.Credentials = new NetworkCredential(settings.Username, settings.Password);
|
||||
}
|
||||
|
||||
await client.SendMailAsync(message);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void Validate(SmtpSettingsDto settings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.RelayHost))
|
||||
{
|
||||
throw new InvalidOperationException("SMTP relay host is empty. Please save SMTP settings first.");
|
||||
}
|
||||
|
||||
if (settings.UseTls && settings.UseSsl)
|
||||
{
|
||||
throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.SenderEmail))
|
||||
{
|
||||
throw new InvalidOperationException("Sender email is empty. Please save sender settings first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,7 @@
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Configuration;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using MemberCenter.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
@ -11,7 +9,6 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using OpenIddict.Abstractions;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.CommandLine;
|
||||
@ -57,7 +54,7 @@ var targetMigrationOption = new Option<string?>(
|
||||
name: "--target",
|
||||
description: "Target migration");
|
||||
|
||||
var initCommand = new Command("init", "Initialize database (migrate + seed + superuser)");
|
||||
var initCommand = new Command("init", "Initialize database (migrate + seed + admin)");
|
||||
initCommand.AddOption(connectionStringOption);
|
||||
initCommand.AddOption(appsettingsOption);
|
||||
initCommand.AddOption(noPromptOption);
|
||||
@ -90,22 +87,19 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
|
||||
|
||||
if (!noPrompt)
|
||||
{
|
||||
adminEmail ??= Prompt("Superuser email", "admin@example.com");
|
||||
adminPassword ??= PromptSecret("Superuser password");
|
||||
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
||||
adminPassword ??= PromptSecret("Admin password");
|
||||
}
|
||||
|
||||
adminEmail ??= "admin@example.com";
|
||||
if (string.IsNullOrWhiteSpace(adminPassword))
|
||||
{
|
||||
Console.Error.WriteLine("Superuser password is required.");
|
||||
Console.Error.WriteLine("Admin password is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
await db.Database.MigrateAsync();
|
||||
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
|
||||
await registry.EnsureDefaultsAsync();
|
||||
|
||||
await EnsureRoleAsync(roleManager, "superuser");
|
||||
await EnsureRoleAsync(roleManager, "admin");
|
||||
await EnsureRoleAsync(roleManager, "support");
|
||||
|
||||
@ -127,9 +121,9 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
|
||||
}
|
||||
}
|
||||
|
||||
if (!await userManager.IsInRoleAsync(admin, "superuser"))
|
||||
if (!await userManager.IsInRoleAsync(admin, "admin"))
|
||||
{
|
||||
await userManager.AddToRoleAsync(admin, "superuser");
|
||||
await userManager.AddToRoleAsync(admin, "admin");
|
||||
}
|
||||
|
||||
await SetInstalledFlagAsync(db);
|
||||
@ -140,8 +134,7 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
|
||||
}
|
||||
}, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption);
|
||||
|
||||
var addAdminCommand = new Command("add-superuser", "Add or elevate superuser");
|
||||
addAdminCommand.AddAlias("add-admin");
|
||||
var addAdminCommand = new Command("add-admin", "Add admin user");
|
||||
addAdminCommand.AddOption(connectionStringOption);
|
||||
addAdminCommand.AddOption(appsettingsOption);
|
||||
addAdminCommand.AddOption(noPromptOption);
|
||||
@ -158,14 +151,14 @@ addAdminCommand.SetHandler(async (string? connectionString, string? appsettings,
|
||||
|
||||
if (!noPrompt)
|
||||
{
|
||||
adminEmail ??= Prompt("Superuser email", "admin@example.com");
|
||||
adminPassword ??= PromptSecret("Superuser password");
|
||||
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
||||
adminPassword ??= PromptSecret("Admin password");
|
||||
}
|
||||
|
||||
adminEmail ??= "admin@example.com";
|
||||
if (string.IsNullOrWhiteSpace(adminPassword))
|
||||
{
|
||||
Console.Error.WriteLine("Superuser password is required.");
|
||||
Console.Error.WriteLine("Admin password is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -174,7 +167,7 @@ addAdminCommand.SetHandler(async (string? connectionString, string? appsettings,
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
|
||||
|
||||
await EnsureRoleAsync(roleManager, "superuser");
|
||||
await EnsureRoleAsync(roleManager, "admin");
|
||||
|
||||
var admin = await userManager.FindByEmailAsync(adminEmail);
|
||||
if (admin is null)
|
||||
@ -194,16 +187,15 @@ addAdminCommand.SetHandler(async (string? connectionString, string? appsettings,
|
||||
}
|
||||
}
|
||||
|
||||
if (!await userManager.IsInRoleAsync(admin, "superuser"))
|
||||
if (!await userManager.IsInRoleAsync(admin, "admin"))
|
||||
{
|
||||
await userManager.AddToRoleAsync(admin, "superuser");
|
||||
await userManager.AddToRoleAsync(admin, "admin");
|
||||
}
|
||||
|
||||
Console.WriteLine("Superuser ready.");
|
||||
Console.WriteLine("Admin user ready.");
|
||||
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
|
||||
|
||||
var resetCommand = new Command("reset-superuser-password", "Reset superuser password");
|
||||
resetCommand.AddAlias("reset-admin-password");
|
||||
var resetCommand = new Command("reset-admin-password", "Reset admin password");
|
||||
resetCommand.AddOption(connectionStringOption);
|
||||
resetCommand.AddOption(appsettingsOption);
|
||||
resetCommand.AddOption(noPromptOption);
|
||||
@ -220,14 +212,14 @@ resetCommand.SetHandler(async (string? connectionString, string? appsettings, bo
|
||||
|
||||
if (!noPrompt)
|
||||
{
|
||||
adminEmail ??= Prompt("Superuser email", "admin@example.com");
|
||||
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
||||
adminPassword ??= PromptSecret("New password");
|
||||
}
|
||||
|
||||
adminEmail ??= "admin@example.com";
|
||||
if (string.IsNullOrWhiteSpace(adminPassword))
|
||||
{
|
||||
Console.Error.WriteLine("Superuser password is required.");
|
||||
Console.Error.WriteLine("Admin password is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -238,7 +230,7 @@ resetCommand.SetHandler(async (string? connectionString, string? appsettings, bo
|
||||
var admin = await userManager.FindByEmailAsync(adminEmail);
|
||||
if (admin is null)
|
||||
{
|
||||
Console.Error.WriteLine("Superuser not found.");
|
||||
Console.Error.WriteLine("Admin not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -272,8 +264,6 @@ migrateCommand.SetHandler(async (string? connectionString, string? appsettings,
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
await db.Database.MigrateAsync();
|
||||
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
|
||||
await registry.EnsureDefaultsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -284,60 +274,10 @@ migrateCommand.SetHandler(async (string? connectionString, string? appsettings,
|
||||
Console.WriteLine("Migrations completed.");
|
||||
}, connectionStringOption, appsettingsOption, targetMigrationOption);
|
||||
|
||||
var syncOAuthClientsCommand = new Command("sync-oauth-clients", "Refresh OAuth client permissions from usage defaults");
|
||||
syncOAuthClientsCommand.AddOption(connectionStringOption);
|
||||
syncOAuthClientsCommand.AddOption(appsettingsOption);
|
||||
syncOAuthClientsCommand.SetHandler(async (string? connectionString, string? appsettings) =>
|
||||
{
|
||||
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt: true);
|
||||
if (string.IsNullOrWhiteSpace(resolvedConnection))
|
||||
{
|
||||
Console.Error.WriteLine("Connection string is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
var services = BuildServices(resolvedConnection);
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
|
||||
await registry.EnsureDefaultsAsync();
|
||||
|
||||
var applicationManager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
|
||||
var applications = new List<object>();
|
||||
await foreach (var application in applicationManager.ListAsync())
|
||||
{
|
||||
applications.Add(application);
|
||||
}
|
||||
|
||||
var updated = 0;
|
||||
foreach (var application in applications)
|
||||
{
|
||||
var properties = await applicationManager.GetPropertiesAsync(application);
|
||||
if (!properties.TryGetValue("usage", out var usageElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var usage = usageElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(usage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var descriptor = new OpenIddictApplicationDescriptor();
|
||||
await applicationManager.PopulateAsync(descriptor, application);
|
||||
await ApplyOAuthClientPermissionsAsync(descriptor, registry, usage);
|
||||
await applicationManager.UpdateAsync(application, descriptor);
|
||||
updated++;
|
||||
}
|
||||
|
||||
Console.WriteLine($"OAuth clients synchronized: {updated}.");
|
||||
}, connectionStringOption, appsettingsOption);
|
||||
|
||||
root.AddCommand(initCommand);
|
||||
root.AddCommand(addAdminCommand);
|
||||
root.AddCommand(resetCommand);
|
||||
root.AddCommand(migrateCommand);
|
||||
root.AddCommand(syncOAuthClientsCommand);
|
||||
|
||||
return await root.InvokeAsync(args);
|
||||
|
||||
@ -345,22 +285,12 @@ static IServiceProvider BuildServices(string connectionString)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddConsole());
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
.Build());
|
||||
services.AddDbContext<MemberCenterDbContext>(options =>
|
||||
{
|
||||
options.UseNpgsql(connectionString);
|
||||
options.UseOpenIddict();
|
||||
});
|
||||
|
||||
services.AddOpenIddict()
|
||||
.AddCore(options =>
|
||||
{
|
||||
options.UseEntityFrameworkCore()
|
||||
.UseDbContext<MemberCenterDbContext>();
|
||||
});
|
||||
|
||||
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = true;
|
||||
@ -373,42 +303,9 @@ static IServiceProvider BuildServices(string connectionString)
|
||||
.AddEntityFrameworkStores<MemberCenterDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
static async Task ApplyOAuthClientPermissionsAsync(
|
||||
OpenIddictApplicationDescriptor descriptor,
|
||||
IAuthResourceRegistryService registry,
|
||||
string usage)
|
||||
{
|
||||
descriptor.Permissions.Clear();
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
||||
|
||||
if (UsesAuthorizationCodeFlow(usage))
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
|
||||
}
|
||||
else
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
||||
}
|
||||
|
||||
var scopes = await registry.GetAllowedScopesForUsageAsync(usage);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope);
|
||||
}
|
||||
}
|
||||
|
||||
static bool UsesAuthorizationCodeFlow(string usage) =>
|
||||
string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static string? ResolveConnectionString(string? connectionString, string? appsettingsPath, bool noPrompt)
|
||||
{
|
||||
var targetPath = ResolveAppsettingsPath(appsettingsPath);
|
||||
|
||||
@ -1,415 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MemberCenter.TestSite.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MemberCenter.TestSite.Controllers;
|
||||
|
||||
public class HomeController : Controller
|
||||
{
|
||||
private const string UserAccessTokenKey = "user_access_token";
|
||||
private const string UserRefreshTokenKey = "user_refresh_token";
|
||||
private const string ServiceAccessTokenKey = "service_access_token";
|
||||
private const string CodeVerifierKey = "code_verifier";
|
||||
private const string LastTitleKey = "last_title";
|
||||
private const string LastBodyKey = "last_body";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public HomeController(IConfiguration configuration, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public IActionResult Index()
|
||||
{
|
||||
return View(BuildViewModel());
|
||||
}
|
||||
|
||||
[HttpPost("auth/redirect-login")]
|
||||
public IActionResult RedirectLogin()
|
||||
{
|
||||
var options = GetOptions();
|
||||
if (string.IsNullOrWhiteSpace(options.WebLoginClientId))
|
||||
{
|
||||
StoreResult("Redirect login", "Configure MemberCenter:WebLoginClientId first.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var codeVerifier = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var codeChallenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier)));
|
||||
HttpContext.Session.SetString(CodeVerifierKey, codeVerifier);
|
||||
|
||||
var callbackUrl = Url.ActionLink(nameof(Callback), "Home");
|
||||
var query = new Dictionary<string, string?>
|
||||
{
|
||||
["client_id"] = options.WebLoginClientId,
|
||||
["redirect_uri"] = callbackUrl,
|
||||
["response_type"] = "code",
|
||||
["scope"] = options.WebLoginScopes,
|
||||
["code_challenge"] = codeChallenge,
|
||||
["code_challenge_method"] = "S256",
|
||||
["state"] = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
return Redirect($"{TrimSlash(options.ApiBaseUrl)}/oauth/authorize?{BuildQuery(query)}");
|
||||
}
|
||||
|
||||
[HttpGet("auth/callback")]
|
||||
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? error, [FromQuery] string? error_description)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
StoreResult("Redirect callback", JsonSerializer.Serialize(new { error, error_description }, JsonOptions));
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
StoreResult("Redirect callback", "Missing authorization code.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var options = GetOptions();
|
||||
var codeVerifier = HttpContext.Session.GetString(CodeVerifierKey);
|
||||
if (string.IsNullOrWhiteSpace(codeVerifier))
|
||||
{
|
||||
StoreResult("Redirect callback", "Missing code verifier in test site session.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var form = new Dictionary<string, string?>
|
||||
{
|
||||
["grant_type"] = "authorization_code",
|
||||
["client_id"] = options.WebLoginClientId,
|
||||
["code"] = code,
|
||||
["redirect_uri"] = Url.ActionLink(nameof(Callback), "Home"),
|
||||
["code_verifier"] = codeVerifier
|
||||
};
|
||||
|
||||
await ExchangeUserTokenAsync("Redirect login token", form);
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("auth/logout")]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
var options = GetOptions();
|
||||
var callbackUrl = Url.ActionLink(nameof(LogoutCallback), "Home");
|
||||
HttpContext.Session.Remove(UserAccessTokenKey);
|
||||
HttpContext.Session.Remove(UserRefreshTokenKey);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(callbackUrl))
|
||||
{
|
||||
StoreResult("Redirect logout", "Unable to build logout callback URL.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
return Redirect($"{TrimSlash(options.WebBaseUrl)}/account/logout?returnUrl={Uri.EscapeDataString(callbackUrl)}");
|
||||
}
|
||||
|
||||
[HttpGet("auth/logout-callback")]
|
||||
public IActionResult LogoutCallback()
|
||||
{
|
||||
HttpContext.Session.Remove(UserAccessTokenKey);
|
||||
HttpContext.Session.Remove(UserRefreshTokenKey);
|
||||
StoreResult("Redirect logout", "Member Center session cleared and TestSite user tokens removed.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("auth/api-login")]
|
||||
public async Task<IActionResult> ApiLogin(string email, string password)
|
||||
{
|
||||
var options = GetOptions();
|
||||
var form = new Dictionary<string, string?>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["username"] = email,
|
||||
["password"] = password,
|
||||
["scope"] = options.WebLoginScopes
|
||||
};
|
||||
|
||||
await ExchangeUserTokenAsync("API login token", form);
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("auth/service-token")]
|
||||
public async Task<IActionResult> ServiceToken(string? clientId, string? clientSecret, string? scopes)
|
||||
{
|
||||
var options = GetOptions();
|
||||
var form = new Dictionary<string, string?>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = string.IsNullOrWhiteSpace(clientId) ? options.ServiceClientId : clientId,
|
||||
["client_secret"] = string.IsNullOrWhiteSpace(clientSecret) ? options.ServiceClientSecret : clientSecret,
|
||||
["scope"] = string.IsNullOrWhiteSpace(scopes) ? options.ServiceScopes : scopes
|
||||
};
|
||||
|
||||
var response = await PostFormAsync("/oauth/token", form);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
SaveToken(body, ServiceAccessTokenKey, null);
|
||||
}
|
||||
|
||||
StoreResult("Service token", FormatResponse(response, body));
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("tests/user-profile-get")]
|
||||
public async Task<IActionResult> UserProfileGet()
|
||||
{
|
||||
await SendUserAsync("GET /user/profile", HttpMethod.Get, "/user/profile");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("tests/user-profile-post")]
|
||||
public async Task<IActionResult> UserProfilePost()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
LastName = "Test",
|
||||
FirstName = "User",
|
||||
NickName = "Member Center Test",
|
||||
MobilePhone = "+886900000000",
|
||||
LandlinePhone = "02-00000000",
|
||||
DateOfBirth = "1990-01-01",
|
||||
Gender = "unspecified",
|
||||
CompanyName = "Test Company",
|
||||
Department = "QA",
|
||||
JobTitle = "Tester",
|
||||
CompanyPhone = "02-11111111",
|
||||
TaxId = "00000000",
|
||||
InvoiceTitle = "Test Company",
|
||||
Remark = "Updated by MemberCenter.TestSite"
|
||||
};
|
||||
await SendUserAsync("POST /user/profile", HttpMethod.Post, "/user/profile", payload);
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("tests/user-addresses-get")]
|
||||
public async Task<IActionResult> UserAddressesGet()
|
||||
{
|
||||
await SendUserAsync("GET /user/addresses", HttpMethod.Get, "/user/addresses");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("tests/user-addresses-post")]
|
||||
public async Task<IActionResult> UserAddressesPost()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
Id = (Guid?)null,
|
||||
Label = "test-site",
|
||||
RecipientName = "Test User",
|
||||
RecipientPhone = "+886900000000",
|
||||
CountryCode = "TW",
|
||||
PostalCode = "100",
|
||||
StateRegion = "",
|
||||
City = "Taipei",
|
||||
District = "Zhongzheng",
|
||||
AddressLine1 = "No. 1, Test Road",
|
||||
AddressLine2 = "Test Building",
|
||||
CompanyName = "Test Company",
|
||||
Usage = "shipping",
|
||||
IsDefault = true,
|
||||
AddressMetaJson = "{\"source\":\"MemberCenter.TestSite\"}"
|
||||
};
|
||||
await SendUserAsync("POST /user/addresses", HttpMethod.Post, "/user/addresses", payload);
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("tests/user-subscriptions-get")]
|
||||
public async Task<IActionResult> UserSubscriptionsGet()
|
||||
{
|
||||
await SendUserAsync("GET /user/subscriptions", HttpMethod.Get, "/user/subscriptions");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("tests/user-subscriptions-unsubscribe")]
|
||||
public async Task<IActionResult> UserSubscriptionsUnsubscribe(Guid subscriptionId)
|
||||
{
|
||||
if (subscriptionId == Guid.Empty)
|
||||
{
|
||||
StoreResult("POST /user/subscriptions/{id}/unsubscribe", "Enter a subscription id first.");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
await SendUserAsync(
|
||||
"POST /user/subscriptions/{id}/unsubscribe",
|
||||
HttpMethod.Post,
|
||||
$"/user/subscriptions/{subscriptionId}/unsubscribe");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("tests/service-profile-by-email")]
|
||||
public async Task<IActionResult> ServiceProfileByEmail(string email)
|
||||
{
|
||||
await SendServiceAsync("GET /user/profile/by-email", HttpMethod.Get, $"/user/profile/by-email?email={Uri.EscapeDataString(email)}");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("tests/service-addresses-by-email")]
|
||||
public async Task<IActionResult> ServiceAddressesByEmail(string email)
|
||||
{
|
||||
await SendServiceAsync("GET /user/addresses/by-email", HttpMethod.Get, $"/user/addresses/by-email?email={Uri.EscapeDataString(email)}");
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost("auth/clear")]
|
||||
public IActionResult ClearTokens()
|
||||
{
|
||||
HttpContext.Session.Clear();
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
public IActionResult Error()
|
||||
{
|
||||
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
|
||||
}
|
||||
|
||||
private TestDashboardViewModel BuildViewModel()
|
||||
{
|
||||
return new TestDashboardViewModel
|
||||
{
|
||||
Options = GetOptions(),
|
||||
UserAccessToken = HttpContext.Session.GetString(UserAccessTokenKey),
|
||||
UserRefreshToken = HttpContext.Session.GetString(UserRefreshTokenKey),
|
||||
ServiceAccessToken = HttpContext.Session.GetString(ServiceAccessTokenKey),
|
||||
LastResponseTitle = HttpContext.Session.GetString(LastTitleKey),
|
||||
LastResponseBody = HttpContext.Session.GetString(LastBodyKey)
|
||||
};
|
||||
}
|
||||
|
||||
private MemberCenterTestOptions GetOptions()
|
||||
{
|
||||
var options = new MemberCenterTestOptions();
|
||||
_configuration.GetSection("MemberCenter").Bind(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
private async Task ExchangeUserTokenAsync(string title, Dictionary<string, string?> form)
|
||||
{
|
||||
var response = await PostFormAsync("/oauth/token", form);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
SaveToken(body, UserAccessTokenKey, UserRefreshTokenKey);
|
||||
}
|
||||
|
||||
StoreResult(title, FormatResponse(response, body));
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> PostFormAsync(string path, Dictionary<string, string?> form)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var values = form
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Value))
|
||||
.ToDictionary(x => x.Key, x => x.Value!);
|
||||
return await client.PostAsync($"{TrimSlash(GetOptions().ApiBaseUrl)}{path}", new FormUrlEncodedContent(values));
|
||||
}
|
||||
|
||||
private async Task SendUserAsync(string title, HttpMethod method, string path, object? payload = null)
|
||||
{
|
||||
await SendApiAsync(title, method, path, HttpContext.Session.GetString(UserAccessTokenKey), payload);
|
||||
}
|
||||
|
||||
private async Task SendServiceAsync(string title, HttpMethod method, string path, object? payload = null)
|
||||
{
|
||||
await SendApiAsync(title, method, path, HttpContext.Session.GetString(ServiceAccessTokenKey), payload);
|
||||
}
|
||||
|
||||
private async Task SendApiAsync(string title, HttpMethod method, string path, string? token, object? payload = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
StoreResult(title, "Missing token. Login or request a service token first.");
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(method, $"{TrimSlash(GetOptions().ApiBaseUrl)}{path}");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
if (payload is not null)
|
||||
{
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
StoreResult(title, FormatResponse(response, body));
|
||||
}
|
||||
|
||||
private void SaveToken(string body, string accessTokenKey, string? refreshTokenKey)
|
||||
{
|
||||
using var json = JsonDocument.Parse(body);
|
||||
if (json.RootElement.TryGetProperty("access_token", out var accessToken))
|
||||
{
|
||||
HttpContext.Session.SetString(accessTokenKey, accessToken.GetString() ?? string.Empty);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(refreshTokenKey)
|
||||
&& json.RootElement.TryGetProperty("refresh_token", out var refreshToken))
|
||||
{
|
||||
HttpContext.Session.SetString(refreshTokenKey, refreshToken.GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreResult(string title, string body)
|
||||
{
|
||||
HttpContext.Session.SetString(LastTitleKey, title);
|
||||
HttpContext.Session.SetString(LastBodyKey, PrettyJsonOrRaw(body));
|
||||
}
|
||||
|
||||
private static string FormatResponse(HttpResponseMessage response, string body)
|
||||
{
|
||||
return $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}\n\n{PrettyJsonOrRaw(body)}";
|
||||
}
|
||||
|
||||
private static string PrettyJsonOrRaw(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return "(empty response)";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(body);
|
||||
return JsonSerializer.Serialize(json.RootElement, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildQuery(Dictionary<string, string?> values)
|
||||
{
|
||||
return string.Join("&", values
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Value))
|
||||
.Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}"));
|
||||
}
|
||||
|
||||
private static string TrimSlash(string value) => value.TrimEnd('/');
|
||||
|
||||
private static string Base64Url(byte[] bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@ -1,8 +0,0 @@
|
||||
namespace MemberCenter.TestSite.Models;
|
||||
|
||||
public class ErrorViewModel
|
||||
{
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
namespace MemberCenter.TestSite.Models;
|
||||
|
||||
public sealed class MemberCenterTestOptions
|
||||
{
|
||||
public string ApiBaseUrl { get; set; } = "http://localhost:7850";
|
||||
public string WebBaseUrl { get; set; } = "http://localhost:5080";
|
||||
public string WebLoginClientId { get; set; } = string.Empty;
|
||||
public string WebLoginRedirectPath { get; set; } = "/auth/callback";
|
||||
public string WebLogoutRedirectPath { get; set; } = "/auth/logout-callback";
|
||||
public string WebLoginScopes { get; set; } = "openid email profile profile:basic.read profile:basic.write profile:addresses.read profile:addresses.write profile:subscriptions.read profile:subscriptions.write";
|
||||
public string ServiceClientId { get; set; } = string.Empty;
|
||||
public string ServiceClientSecret { get; set; } = string.Empty;
|
||||
public string ServiceScopes { get; set; } = "profile:basic.read profile:addresses.read";
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
namespace MemberCenter.TestSite.Models;
|
||||
|
||||
public sealed class TestDashboardViewModel
|
||||
{
|
||||
public MemberCenterTestOptions Options { get; set; } = new();
|
||||
public string? UserAccessToken { get; set; }
|
||||
public string? UserRefreshToken { get; set; }
|
||||
public string? ServiceAccessToken { get; set; }
|
||||
public string? LastResponseTitle { get; set; }
|
||||
public string? LastResponseBody { get; set; }
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSession(options =>
|
||||
{
|
||||
options.Cookie.Name = ".MemberCenter.TestSite.Session";
|
||||
options.IdleTimeout = TimeSpan.FromHours(2);
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.IsEssential = true;
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Home/Error");
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
app.UseSession();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllerRoute(
|
||||
name: "default",
|
||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||
|
||||
app.Run();
|
||||
@ -1,38 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:9196",
|
||||
"sslPort": 44343
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5243",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7046;http://localhost:5243",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
@model MemberCenter.TestSite.Models.TestDashboardViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Member Center Test Site";
|
||||
string ShortToken(string? token) => string.IsNullOrWhiteSpace(token)
|
||||
? "not set"
|
||||
: $"{token[..Math.Min(18, token.Length)]}...";
|
||||
}
|
||||
|
||||
<div class="test-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Member Center Integration Test</p>
|
||||
<h1>API / Redirect Login Happy Paths</h1>
|
||||
<p>This test site stores tokens in its own ASP.NET session and runs the first 10 happy-path checks against Member Center.</p>
|
||||
</div>
|
||||
<form method="post" asp-action="ClearTokens">
|
||||
<button type="submit" class="danger">Clear Session Tokens</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section class="grid">
|
||||
<article class="card">
|
||||
<h2>Current Settings</h2>
|
||||
<dl>
|
||||
<dt>API Base URL</dt>
|
||||
<dd>@Model.Options.ApiBaseUrl</dd>
|
||||
<dt>Web Base URL</dt>
|
||||
<dd>@Model.Options.WebBaseUrl</dd>
|
||||
<dt>web_login Client ID</dt>
|
||||
<dd>@(string.IsNullOrWhiteSpace(Model.Options.WebLoginClientId) ? "not configured" : Model.Options.WebLoginClientId)</dd>
|
||||
<dt>Service Client ID</dt>
|
||||
<dd>@(string.IsNullOrWhiteSpace(Model.Options.ServiceClientId) ? "not configured" : Model.Options.ServiceClientId)</dd>
|
||||
<dt>User token</dt>
|
||||
<dd>@ShortToken(Model.UserAccessToken)</dd>
|
||||
<dt>Service token</dt>
|
||||
<dd>@ShortToken(Model.ServiceAccessToken)</dd>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>1. Redirect Login</h2>
|
||||
<p>Uses <code>usage=web_login</code>, Authorization Code + PKCE, and this site's <code>/auth/callback</code>.</p>
|
||||
<form method="post" asp-action="RedirectLogin">
|
||||
<button type="submit">Start Redirect Login</button>
|
||||
</form>
|
||||
<form method="post" asp-action="Logout">
|
||||
<button type="submit">Direct Logout</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>2. API Login</h2>
|
||||
<form method="post" asp-action="ApiLogin">
|
||||
<label>Email</label>
|
||||
<input name="email" type="email" autocomplete="username" required />
|
||||
<label>Password</label>
|
||||
<input name="password" type="password" autocomplete="current-password" required />
|
||||
<button type="submit">Get User Token</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Service Token</h2>
|
||||
<p>Required for checks 9 and 10.</p>
|
||||
<form method="post" asp-action="ServiceToken">
|
||||
<label>Client ID</label>
|
||||
<input name="clientId" value="@Model.Options.ServiceClientId" />
|
||||
<label>Client Secret</label>
|
||||
<input name="clientSecret" type="password" value="@Model.Options.ServiceClientSecret" />
|
||||
<label>Scopes</label>
|
||||
<input name="scopes" value="@Model.Options.ServiceScopes" />
|
||||
<button type="submit">Get Service Token</button>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>User Token Happy Paths</h2>
|
||||
<div class="actions">
|
||||
<form method="post" asp-action="UserProfileGet">
|
||||
<button type="submit">3. GET /user/profile</button>
|
||||
</form>
|
||||
<form method="post" asp-action="UserProfilePost">
|
||||
<button type="submit">4. POST /user/profile</button>
|
||||
</form>
|
||||
<form method="post" asp-action="UserAddressesGet">
|
||||
<button type="submit">5. GET /user/addresses</button>
|
||||
</form>
|
||||
<form method="post" asp-action="UserAddressesPost">
|
||||
<button type="submit">6. POST /user/addresses</button>
|
||||
</form>
|
||||
<form method="post" asp-action="UserSubscriptionsGet">
|
||||
<button type="submit">7. GET /user/subscriptions</button>
|
||||
</form>
|
||||
</div>
|
||||
<form method="post" asp-action="UserSubscriptionsUnsubscribe" class="inline-form">
|
||||
<label>Subscription ID</label>
|
||||
<input name="subscriptionId" placeholder="00000000-0000-0000-0000-000000000000" />
|
||||
<button type="submit">8. POST /user/subscriptions/{id}/unsubscribe</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Service Token Happy Paths</h2>
|
||||
<p>Use a service token with at least <code>profile:basic.read</code> and <code>profile:addresses.read</code>.</p>
|
||||
<form method="post" asp-action="ServiceProfileByEmail" class="inline-form">
|
||||
<label>Email</label>
|
||||
<input name="email" type="email" required />
|
||||
<button type="submit">9. GET /user/profile/by-email</button>
|
||||
</form>
|
||||
<form method="post" asp-action="ServiceAddressesByEmail" class="inline-form">
|
||||
<label>Email</label>
|
||||
<input name="email" type="email" required />
|
||||
<button type="submit">10. GET /user/addresses/by-email</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card response-card">
|
||||
<h2>Last Response</h2>
|
||||
<h3>@(Model.LastResponseTitle ?? "No request yet")</h3>
|
||||
<pre>@(Model.LastResponseBody ?? "Run a happy-path action to see the response here.")</pre>
|
||||
</section>
|
||||
@ -1,6 +0,0 @@
|
||||
@{
|
||||
ViewData["Title"] = "Privacy Policy";
|
||||
}
|
||||
<h1>@ViewData["Title"]</h1>
|
||||
|
||||
<p>Use this page to detail your site's privacy policy.</p>
|
||||
@ -1,25 +0,0 @@
|
||||
@model ErrorViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Error";
|
||||
}
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (Model.ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@Model.RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
@ -1,46 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@ViewData["Title"] - Member Center Test Site</title>
|
||||
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/MemberCenter.TestSite.styles.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">Member Center Test Site</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
|
||||
aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
|
||||
<ul class="navbar-nav flex-grow-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="container">
|
||||
<main role="main" class="pb-3">
|
||||
@RenderBody()
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="border-top footer text-muted">
|
||||
<div class="container">
|
||||
© 2026 - Member Center Test Site
|
||||
</div>
|
||||
</footer>
|
||||
<script src="~/lib/jquery/dist/jquery.min.js"></script>
|
||||
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
@ -1,48 +0,0 @@
|
||||
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
||||
for details on configuring this project to bundle and minify static web assets. */
|
||||
|
||||
a.navbar-brand {
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0077cc;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.border-top {
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.box-shadow {
|
||||
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
|
||||
}
|
||||
|
||||
button.accept-policy {
|
||||
font-size: 1rem;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
line-height: 60px;
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
|
||||
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
|
||||
@ -1,3 +0,0 @@
|
||||
@using MemberCenter.TestSite
|
||||
@using MemberCenter.TestSite.Models
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@ -1,3 +0,0 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
{
|
||||
"MemberCenter": {
|
||||
"ApiBaseUrl": "http://localhost:7850",
|
||||
"WebBaseUrl": "http://localhost:5080",
|
||||
"WebLoginClientId": "f48329ef38c54a62b627585a75c9b5d5",
|
||||
"WebLoginRedirectPath": "/auth/callback",
|
||||
"WebLogoutRedirectPath": "/auth/logout-callback",
|
||||
"WebLoginScopes": "openid email profile profile:basic.read profile:basic.write profile:addresses.read profile:addresses.write profile:subscriptions.read profile:subscriptions.write",
|
||||
"ServiceClientId": "e9fe7ae413c54ae49432eca6474648d3",
|
||||
"ServiceClientSecret": "To/WaVObQgxCaZwzGexfp/pvUwI2G5o1r55v4sRukYw=",
|
||||
"ServiceScopes": "profile:basic.read profile:addresses.read"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@ -1,161 +0,0 @@
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
html {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin-bottom: 60px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(25, 135, 84, 0.12), transparent 30rem),
|
||||
linear-gradient(180deg, #f7f3ec 0%, #f9faf7 100%);
|
||||
color: #1f2a24;
|
||||
}
|
||||
|
||||
.test-hero {
|
||||
align-items: center;
|
||||
background: #16382c;
|
||||
border-radius: 28px;
|
||||
color: #f7f3ec;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
justify-content: space-between;
|
||||
margin: 2rem 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.test-hero h1 {
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
line-height: 0.95;
|
||||
margin: 0;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.test-hero p {
|
||||
margin-bottom: 0;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #ffc857;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(22, 56, 44, 0.12);
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 20px 50px rgba(22, 56, 44, 0.08);
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label,
|
||||
dt {
|
||||
color: #53645d;
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
margin-top: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-left: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 1px solid #cdd7d1;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #236b52;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
margin-top: 1rem;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1a533f;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #b9472f;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.actions form,
|
||||
.inline-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: minmax(220px, 1fr) auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.inline-form label {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.response-card pre {
|
||||
background: #10241d;
|
||||
border-radius: 18px;
|
||||
color: #dcf7ea;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.test-hero,
|
||||
.inline-form {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 KiB |
@ -1,4 +0,0 @@
|
||||
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
||||
// for details on configuring this project to bundle and minify static web assets.
|
||||
|
||||
// Write your JavaScript code.
|
||||
@ -1,22 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2011-2021 Twitter, Inc.
|
||||
Copyright (c) 2011-2021 The Bootstrap Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user