Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fedb011154 | ||
|
|
6729f91275 | ||
|
|
e77fdec76b | ||
|
|
09589ef631 | ||
|
|
f9a66dccad | ||
|
|
5f32452263 | ||
|
|
f1077d0801 | ||
|
|
8585190123 | ||
|
|
02874677be | ||
|
|
028cc39a40 | ||
|
|
c9c0396ad2 | ||
|
|
5752d649e0 | ||
|
|
2cbef03ef7 | ||
|
|
766ecf702f | ||
|
|
75e235b8e3 |
@ -1,6 +1,9 @@
|
||||
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,6 +17,8 @@ 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
|
||||
@ -50,6 +52,10 @@ 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}
|
||||
@ -58,5 +64,6 @@ 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,13 +44,16 @@
|
||||
- `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(會員與後台頁面)
|
||||
│ ├── MemberCenter.Web/ # MVC Web UI(client-first 會員介面 + Areas/Admin 管理介面)
|
||||
│ ├── MemberCenter.TestSite/ # 手動整合測試站(API / redirect login happy path)
|
||||
│ ├── MemberCenter.Installer/ # 安裝與初始化 CLI(migrate/init/admin)
|
||||
│ ├── MemberCenter.Application/ # 應用層介面與 DTO
|
||||
│ ├── MemberCenter.Infrastructure/# EF Core、Identity、OpenIddict、服務實作
|
||||
@ -66,3 +69,11 @@ 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` 事件。
|
||||
|
||||
260
docs/ADMIN_CLIENT_SPLIT_PLAN.md
Normal file
260
docs/ADMIN_CLIENT_SPLIT_PLAN.md
Normal file
@ -0,0 +1,260 @@
|
||||
# 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,11 +12,13 @@
|
||||
## 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
|
||||
|
||||
@ -28,8 +30,10 @@
|
||||
- 站點後台:可管理站點資訊、訂閱清單、會員基本資料
|
||||
|
||||
## 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:驗證信/退訂信/確認信的發送介面(外部系統)
|
||||
@ -39,11 +43,26 @@
|
||||
- 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
|
||||
- id, user_name, email, password_hash, email_confirmed, lockout, is_blacklisted, blacklisted_at, blacklisted_by, created_at, last_login_at, last_seen_at, disabled_at, disabled_by
|
||||
- user_profiles
|
||||
- user_id, last_name, first_name, nick_name, mobile_phone, landline_phone, date_of_birth, gender, company_name, department, job_title, company_phone, tax_id, invoice_title, remark, updated_at
|
||||
- user_addresses
|
||||
- id, user_id, label, recipient_name, recipient_phone, country_code, postal_code, state_region, city, district, address_line1, address_line2, company_name, usage, is_default, address_meta_json, created_at, updated_at
|
||||
|
||||
資料約束建議:
|
||||
- `user_profiles.first_name`、`user_profiles.last_name` 必填
|
||||
- `user_profiles.gender` 使用列舉:`male` / `female` / `other` / `unspecified`
|
||||
- `user_profiles.date_of_birth` 使用 `date`
|
||||
- `user_addresses.country_code` 使用 ISO 3166-1 alpha-2
|
||||
- `user_addresses.address_line1` 必填
|
||||
- `user_addresses.usage` 使用列舉:`shipping` / `billing` / `both`
|
||||
- 同一個 `user_id + usage` 只允許一筆預設地址
|
||||
- 地址至少保留一筆,禁止刪除最後一筆地址
|
||||
- `address_meta_json` 作為國家特有欄位補充,不取代主結構欄位
|
||||
- roles / user_roles (Identity)
|
||||
- id, name, created_at
|
||||
- OpenIddictApplications
|
||||
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|send_api|webhook_outbound|platform_service`)
|
||||
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|send_api|web_login|webhook_outbound|platform_service|file_api`)
|
||||
- OpenIddictAuthorizations
|
||||
- id, application_id, status, subject, type, scopes
|
||||
- OpenIddictTokens
|
||||
@ -60,6 +79,8 @@
|
||||
- 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
|
||||
@ -70,14 +91,26 @@
|
||||
- 使用者註冊時,如 email 存在訂閱紀錄,補上 user_id
|
||||
- 單一清單退訂:unsubscribe token 綁定 subscription_id
|
||||
- blacklist 記錄於 email_blacklist(全租戶共用)
|
||||
- email 為會員與訂閱領域的對外主 key,不提供改 email 流程
|
||||
|
||||
## 6. 核心流程
|
||||
|
||||
### 6.1 OAuth2/OIDC Redirect 登入(Authorization Code + PKCE)
|
||||
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`
|
||||
狀態:已支援 `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`。
|
||||
|
||||
### 6.2 OAuth2 API 使用(站點自行 UI)
|
||||
1) 站點以 API 驗證使用者登入(會員中心提供 login API)
|
||||
@ -124,6 +157,112 @@
|
||||
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`
|
||||
@ -153,24 +292,43 @@
|
||||
|
||||
### Auth / Scope
|
||||
- `tenant_api` / `send_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
|
||||
- OAuth Client 需區分用途:`tenant_api` / `send_api` / `webhook_outbound` / `platform_service`(禁止混用)
|
||||
- OAuth Client 需區分用途:`tenant_api` / `send_api` / `webhook_outbound` / `platform_service` / `file_api`(禁止混用)
|
||||
- 新增 scope:`newsletter:list.read`、`newsletter:send.write`、`newsletter:send.read`、`newsletter:events.read`
|
||||
- 新增 scope:`newsletter:events.write`
|
||||
- 新增 scope:`newsletter:events.write.global`
|
||||
- 規劃新增 profile scopes:
|
||||
- `profile:basic.read`
|
||||
- `profile:basic.write`
|
||||
- `profile:addresses.read`
|
||||
- `profile:addresses.write`
|
||||
- `profile:subscriptions.read`
|
||||
- `profile:subscriptions.write`
|
||||
- 規劃新增 file access scopes:
|
||||
- `files:upload.write`
|
||||
- `files:download.read`
|
||||
- `files:download.delegate`
|
||||
- `files:delete`
|
||||
- `files:metadata.read`
|
||||
- 規劃新增 audience:`file_access_api`
|
||||
- JWT Access Token 已改為 JWS(`DisableAccessTokenEncryption`),供 Send Engine 以 JWKS 驗簽
|
||||
- `aud` 計算由 resource registry 驅動,不於 token 發放流程硬寫各服務 audience
|
||||
|
||||
### 租戶端取 Token(Client Credentials)
|
||||
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
|
||||
- token 內含 `tenant_id` 與 scope
|
||||
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS)
|
||||
- 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫
|
||||
- File Access 與未來外部服務亦沿用此模型;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` 補值)尚未在註冊流程落地
|
||||
- `subscription.linked_to_user` 事件尚未發送
|
||||
- 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)已在註冊 / external login 流程落地
|
||||
- `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)
|
||||
@ -178,6 +336,8 @@
|
||||
- Redirect URI 白名單 + PKCE
|
||||
- Double opt-in(可配置)
|
||||
- Audit log
|
||||
- delegated download token 需短效、不可重放,必要時可引入 `jti` 與 nonce/jti blacklist
|
||||
- Email 可作為未來 MFA 的挑戰通道
|
||||
- GDPR/CCPA:資料匯出與刪除(規劃中)
|
||||
|
||||
## 9. 其他文件
|
||||
|
||||
@ -11,8 +11,19 @@
|
||||
## F-02 登入(OAuth2 + OIDC)
|
||||
- [API] 站點送出 `POST /auth/login` 取得 access_token + id_token
|
||||
- [API] 站點建立自身 session
|
||||
- [UI] 導向 `/oauth/authorize` 完成授權碼流程
|
||||
- [UI] 站點用 code 換 token + id_token
|
||||
- [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
|
||||
|
||||
## F-02b 內容站台呼叫 Send Engine(Client Credentials + JWT 驗簽)
|
||||
- [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token(`usage=send_api`)
|
||||
@ -20,11 +31,37 @@
|
||||
- [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 訂閱並發送驗證信
|
||||
@ -48,6 +85,15 @@
|
||||
- [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`
|
||||
@ -75,11 +121,27 @@
|
||||
- [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,12 +41,33 @@
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||
Auth__Issuer=http://localhost:7850/
|
||||
Auth__MemberCenterAudience=member_center_api
|
||||
Auth__SendEngineAudience=send_engine_api
|
||||
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
|
||||
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` 一致
|
||||
@ -74,12 +95,12 @@ SendEngine__WebhookSecret=change-me
|
||||
- 若 appsettings 中缺少連線字串,會互動式詢問並寫入
|
||||
- 若設定環境變數,會優先使用環境變數(不寫入 appsettings)
|
||||
2) 執行 migrations(不 Drop)
|
||||
3) 建立 roles(admin, support)
|
||||
4) 建立 admin(不存在才建立)並加入 admin 角色
|
||||
3) 建立 roles(superuser, admin, support)
|
||||
4) 建立使用者(不存在才建立)並加入 `superuser` 角色
|
||||
5) 寫入安裝鎖定(DB flag: `system_flags` / `installed=true`)
|
||||
|
||||
### 2) `installer add-admin`
|
||||
用途:新增 superuser
|
||||
### 2) `installer add-superuser`
|
||||
用途:新增或提升 superuser
|
||||
|
||||
參數:
|
||||
- `--admin-email <email>`
|
||||
@ -88,10 +109,13 @@ SendEngine__WebhookSecret=change-me
|
||||
|
||||
流程:
|
||||
1) 解析連線字串
|
||||
2) 建立使用者並指派 admin 角色
|
||||
2) 建立使用者並指派 `superuser` 角色
|
||||
|
||||
### 3) `installer reset-admin-password`
|
||||
用途:重設指定 admin 密碼
|
||||
相容性:
|
||||
- 舊指令 `installer add-admin` 仍保留為 alias,目前語意等同 `installer add-superuser`
|
||||
|
||||
### 3) `installer reset-superuser-password`
|
||||
用途:重設指定 superuser 密碼
|
||||
|
||||
參數:
|
||||
- `--admin-email <email>`
|
||||
@ -101,6 +125,9 @@ SendEngine__WebhookSecret=change-me
|
||||
1) 解析連線字串
|
||||
2) 更新密碼(強制)
|
||||
|
||||
相容性:
|
||||
- 舊指令 `installer reset-admin-password` 仍保留為 alias,目前語意等同 `installer reset-superuser-password`
|
||||
|
||||
### 4) `installer migrate`
|
||||
用途:只執行 migrations
|
||||
|
||||
|
||||
791
docs/MEMBER_UPGRADE_PLAN.md
Normal file
791
docs/MEMBER_UPGRADE_PLAN.md
Normal file
@ -0,0 +1,791 @@
|
||||
# 會員中心升級規劃
|
||||
|
||||
此文件整理會員中心的目標升級架構,包含設定畫面、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__SendEngineAudience` 覆寫)
|
||||
- `aud`:Send Engine 流程預設 `send_engine_api`,由 Auth resource registry 決定;舊版 `Auth__SendEngineAudience` 仍作為相容 seed 來源
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -72,6 +72,16 @@
|
||||
- `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 呼叫
|
||||
@ -80,8 +90,8 @@
|
||||
- `usage=platform_service`:
|
||||
- 供平台級 S2S(例如 SES 聚合事件回寫)
|
||||
- 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global`
|
||||
- `tenant_api` / `send_api` / `platform_service` 建議(且實作要求)`client_type=confidential`
|
||||
- `redirect_uris` 僅 `webhook_outbound` 需要;其他 usage 可為空
|
||||
- `tenant_api` / `send_api` / `platform_service` / `file_api` 建議(且實作要求)`client_type=confidential`
|
||||
- `redirect_uris` 僅 `web_login` / `webhook_outbound` 需要;其他 usage 可為空
|
||||
- 管理規則:
|
||||
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
||||
- 平台級流程另建 `platform_service` 憑證
|
||||
@ -98,7 +108,7 @@
|
||||
- `POST /newsletter/one-click-unsubscribe-tokens`:已實作(Send Engine 批次申請 one-click token)
|
||||
|
||||
### Auth / Scope
|
||||
- `tenant_api` / `send_api` / `webhook_outbound` 類型需綁定 `tenant_id`
|
||||
- `tenant_api` / `send_api` / `file_api` / `webhook_outbound` 類型需綁定 `tenant_id`
|
||||
- `platform_service` 可不綁定 `tenant_id`
|
||||
- 新增 scope:
|
||||
- `newsletter:list.read`
|
||||
@ -107,6 +117,22 @@
|
||||
- `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)
|
||||
@ -114,9 +140,48 @@
|
||||
- 建議 Send Engine 使用 client credentials 取 token,不建議使用長效固定 token
|
||||
- Send Engine 建議以 JWKS 驗簽 JWT(JWS),並驗證 `scope/tenant_id/exp`
|
||||
- `iss` 由 `Auth:Issuer` 設定(例:`http://localhost:7850/`)
|
||||
- `aud` 預設:
|
||||
- Send Engine 流程:`send_engine_api`(可用 `Auth:SendEngineAudience` 覆寫)
|
||||
- Member Center API 流程:`member_center_api`(可用 `Auth:MemberCenterAudience` 覆寫)
|
||||
- `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 驗證
|
||||
|
||||
### 回寫原因碼(Send Engine -> Member Center)
|
||||
- `hard_bounce`
|
||||
|
||||
69
docs/TEST_SITE.md
Normal file
69
docs/TEST_SITE.md
Normal file
@ -0,0 +1,69 @@
|
||||
# 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,10 +2,11 @@
|
||||
|
||||
## 會員中心(統一 UI)
|
||||
### 會員端
|
||||
- 註冊 / 登入 / 忘記密碼
|
||||
- 註冊 / 登入 / 忘記密碼 / 修改密碼
|
||||
- Email 驗證
|
||||
- 個人資料(Email 為主)
|
||||
- 訂閱管理(清單與偏好)
|
||||
- 個人資料(基本資料、聯絡方式、公司資訊)
|
||||
- 收貨地址簿
|
||||
- 訂閱管理(清單與退訂)
|
||||
- 退訂(單一清單)
|
||||
- 連結外站(可選:回到來源站點)
|
||||
|
||||
@ -16,13 +17,14 @@
|
||||
- 訂閱查詢 / 匯出
|
||||
- 審計紀錄查詢
|
||||
- 系統設定(安全策略、token 時效)
|
||||
- Auth 資源設定(resource / audience / scope / usage mapping)
|
||||
|
||||
## 各站自建 UI(API)
|
||||
### 會員端
|
||||
- 登入 / 註冊 / 忘記密碼
|
||||
- 登入 / 註冊 / 忘記密碼 / 修改密碼
|
||||
- Email 驗證頁(可自建或導回會員中心)
|
||||
- 訂閱表單(未登入)
|
||||
- 訂閱偏好管理(登入後)
|
||||
- 外站自建訂閱偏好管理(登入後,走 API)
|
||||
- 退訂頁(從 email token 進來)
|
||||
|
||||
### 管理者端
|
||||
@ -33,26 +35,36 @@
|
||||
- 會員中心 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`
|
||||
- UC-03 登出: `POST /account/logout`(外站 direct logout 可導向 `GET /account/logout?returnUrl=...`)
|
||||
- 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 訂閱偏好管理(登入後): `/newsletter/preferences?list_id=...&email=...`
|
||||
- UC-10 會員資料查看: `/profile`
|
||||
- UC-09 訂閱偏好管理(外站整合 API): `/newsletter/preferences?list_id=...&email=...`
|
||||
- UC-10 會員資料查看 / 編輯: `/profile`
|
||||
- UC-10.1 收貨地址簿管理: `/profile/addresses`
|
||||
- UC-10.2 我的電子報訂閱: `/profile/subscriptions`
|
||||
|
||||
### 管理者端(統一 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` / `webhook_outbound` / `platform_service`;`platform_service` 可不指定 tenant)
|
||||
- `redirect_uris` 僅 `webhook_outbound` 需要;其餘 usage 不需要
|
||||
- `tenant_api` / `send_api` / `platform_service` 強制 `client_type=confidential`
|
||||
- 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-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,11 +9,12 @@
|
||||
- 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/UI]
|
||||
- UC-09 訂閱偏好管理(登入後) [API]
|
||||
- UC-10 會員資料查看(Email 為主) [API/UI]
|
||||
|
||||
## 管理者端
|
||||
@ -31,4 +32,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 (revoke refresh token)
|
||||
summary: Logout current authenticated session
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
@ -810,6 +810,48 @@ 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:
|
||||
@ -828,6 +870,11 @@ 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:
|
||||
@ -837,6 +884,11 @@ 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
|
||||
@ -917,6 +969,52 @@ 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:
|
||||
@ -1002,7 +1100,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] }
|
||||
event_type: { type: string, enum: [subscription.activated, subscription.unsubscribed, preferences.updated, subscription.linked_to_user] }
|
||||
tenant_id: { type: string }
|
||||
list_id: { type: string }
|
||||
subscriber:
|
||||
@ -1071,6 +1169,6 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
name: { type: string }
|
||||
usage: { type: string, enum: [tenant_api, send_api, webhook_outbound, platform_service] }
|
||||
usage: { type: string, enum: [tenant_api, send_api, web_login, webhook_outbound, platform_service, file_api] }
|
||||
redirect_uris: { type: array, items: { type: string } }
|
||||
client_type: { type: string, enum: [public, confidential] }
|
||||
|
||||
18
src/MemberCenter.Api/Contracts/FileAccessRequests.cs
Normal file
18
src/MemberCenter.Api/Contracts/FileAccessRequests.cs
Normal file
@ -0,0 +1,18 @@
|
||||
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);
|
||||
34
src/MemberCenter.Api/Contracts/ProfileRequests.cs
Normal file
34
src/MemberCenter.Api/Contracts/ProfileRequests.cs
Normal file
@ -0,0 +1,34 @@
|
||||
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,4 +1,5 @@
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OpenIddict.Abstractions;
|
||||
@ -12,10 +13,14 @@ namespace MemberCenter.Api.Controllers;
|
||||
public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||
private readonly IAuthResourceRegistryService _authResourceRegistry;
|
||||
|
||||
public AdminOAuthClientsController(IOpenIddictApplicationManager applicationManager)
|
||||
public AdminOAuthClientsController(
|
||||
IOpenIddictApplicationManager applicationManager,
|
||||
IAuthResourceRegistryService authResourceRegistry)
|
||||
{
|
||||
_applicationManager = applicationManager;
|
||||
_authResourceRegistry = authResourceRegistry;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@ -43,7 +48,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
if (!IsValidUsage(request.Usage))
|
||||
{
|
||||
return BadRequest("usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
|
||||
return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api.");
|
||||
}
|
||||
|
||||
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
||||
@ -64,7 +69,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
}
|
||||
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
|
||||
{
|
||||
return BadRequest("redirect_uris is required for webhook_outbound usage.");
|
||||
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
|
||||
}
|
||||
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
@ -73,7 +78,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
DisplayName = request.Name,
|
||||
ClientType = request.ClientType
|
||||
};
|
||||
ApplyPermissions(descriptor, request.Usage);
|
||||
await ApplyPermissionsAsync(descriptor, request.Usage);
|
||||
|
||||
foreach (var uri in redirectUris)
|
||||
{
|
||||
@ -122,7 +127,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
{
|
||||
if (!IsValidUsage(request.Usage))
|
||||
{
|
||||
return BadRequest("usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
|
||||
return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, platform_service, or file_api.");
|
||||
}
|
||||
|
||||
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
||||
@ -143,7 +148,7 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
}
|
||||
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
|
||||
{
|
||||
return BadRequest("redirect_uris is required for webhook_outbound usage.");
|
||||
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
|
||||
}
|
||||
|
||||
var app = await _applicationManager.FindByIdAsync(id);
|
||||
@ -157,7 +162,12 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
|
||||
descriptor.DisplayName = request.Name;
|
||||
descriptor.ClientType = request.ClientType;
|
||||
ApplyPermissions(descriptor, request.Usage);
|
||||
if (string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descriptor.ClientSecret = null;
|
||||
}
|
||||
|
||||
await ApplyPermissionsAsync(descriptor, request.Usage);
|
||||
descriptor.RedirectUris.Clear();
|
||||
foreach (var uri in redirectUris)
|
||||
{
|
||||
@ -201,25 +211,30 @@ 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, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "file_api", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsTenantOptionalUsage(string usage)
|
||||
{
|
||||
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
||||
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "web_login", 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, "send_api", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "file_api", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool UsesAuthorizationCodeFlow(string usage)
|
||||
{
|
||||
return string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
||||
return string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static (List<string> Uris, string? Error) NormalizeRedirectUris(List<string>? redirectUris)
|
||||
@ -246,38 +261,27 @@ public class AdminOAuthClientsController : ControllerBase
|
||||
return (values, null);
|
||||
}
|
||||
|
||||
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
|
||||
private async Task ApplyPermissionsAsync(OpenIddictApplicationDescriptor descriptor, string usage)
|
||||
{
|
||||
descriptor.Permissions.Clear();
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
||||
|
||||
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
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");
|
||||
var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
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;
|
||||
|
||||
@ -10,42 +13,53 @@ 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(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
|
||||
public AuthController(
|
||||
IAccountProvisioningService accountProvisioningService,
|
||||
IAccountEmailService accountEmailService,
|
||||
IAuditLogWriter auditLogWriter,
|
||||
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 user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = request.Email,
|
||||
Email = request.Email,
|
||||
EmailConfirmed = false
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, request.Password);
|
||||
var result = await _accountProvisioningService.RegisterLocalAsync(request.Email, request.Password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return BadRequest(result.Errors.Select(e => e.Description));
|
||||
return BadRequest(result.Errors);
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByEmailAsync(request.Email);
|
||||
if (user is not null)
|
||||
{
|
||||
await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl());
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
id = user.Id,
|
||||
email = user.Email,
|
||||
email_verified = user.EmailConfirmed,
|
||||
created_at = user.CreatedAt
|
||||
id = result.UserId,
|
||||
email = result.Email,
|
||||
email_verified = result.EmailConfirmed,
|
||||
linked_subscriptions = result.LinkedSubscriptionsCount
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("password/forgot")]
|
||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
|
||||
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(request.Email);
|
||||
@ -54,8 +68,8 @@ public class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||
return Ok(new { token });
|
||||
await _accountEmailService.SendPasswordResetEmailAsync(user.Id, GetBaseUrl());
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("password/reset")]
|
||||
@ -73,6 +87,11 @@ 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();
|
||||
}
|
||||
|
||||
@ -91,9 +110,33 @@ 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)
|
||||
@ -101,4 +144,6 @@ public class AuthController : ControllerBase
|
||||
await _signInManager.SignOutAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
|
||||
}
|
||||
|
||||
204
src/MemberCenter.Api/Controllers/FileAccessController.cs
Normal file
204
src/MemberCenter.Api/Controllers/FileAccessController.cs
Normal file
@ -0,0 +1,204 @@
|
||||
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,7 +1,9 @@
|
||||
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;
|
||||
@ -18,6 +20,7 @@ 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);
|
||||
@ -76,6 +79,7 @@ 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,28 +1,35 @@
|
||||
using MemberCenter.Api.Extensions;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using System.Security.Claims;
|
||||
using System.Web;
|
||||
|
||||
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(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
|
||||
public OAuthController(
|
||||
IConfiguration configuration,
|
||||
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;
|
||||
@ -31,14 +38,31 @@ public class OAuthController : ControllerBase
|
||||
return BadRequest("Invalid OpenIddict request.");
|
||||
}
|
||||
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
var cookie = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
|
||||
if (!cookie.Succeeded || cookie.Principal is null)
|
||||
{
|
||||
return Redirect(BuildLoginRedirectUrl());
|
||||
}
|
||||
|
||||
var user = await _userManager.GetUserAsync(cookie.Principal);
|
||||
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));
|
||||
@ -46,4 +70,11 @@ 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,4 +1,5 @@
|
||||
using MemberCenter.Api.Extensions;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -13,23 +14,22 @@ namespace MemberCenter.Api.Controllers;
|
||||
[ApiController]
|
||||
public class TokenController : ControllerBase
|
||||
{
|
||||
private readonly string _memberCenterAudience;
|
||||
private readonly string _sendEngineAudience;
|
||||
private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp";
|
||||
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)
|
||||
IOpenIddictApplicationManager applicationManager,
|
||||
IAuthResourceRegistryService authResourceRegistry)
|
||||
{
|
||||
_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,31 +46,40 @@ public class TokenController : ControllerBase
|
||||
if (request.IsPasswordGrantType())
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(request.Username ?? string.Empty);
|
||||
if (user is null)
|
||||
if (user is null || user.DisabledAt.HasValue)
|
||||
{
|
||||
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
var valid = await _userManager.CheckPasswordAsync(user, request.Password ?? string.Empty);
|
||||
if (!valid)
|
||||
var signInResult = await _signInManager.CheckPasswordSignInAsync(user, request.Password ?? string.Empty, true);
|
||||
if (!signInResult.Succeeded)
|
||||
{
|
||||
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(ResolveResources(scopes));
|
||||
principal.SetResources(await ResolveResourcesAsync(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.IsRefreshTokenGrantType())
|
||||
if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
|
||||
{
|
||||
var authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
if (!authenticateResult.Succeeded || authenticateResult.Principal is null)
|
||||
@ -79,6 +88,11 @@ public class TokenController : ControllerBase
|
||||
}
|
||||
|
||||
var principal = authenticateResult.Principal;
|
||||
if (!await ValidateUserPrincipalAsync(principal))
|
||||
{
|
||||
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
@ -129,7 +143,7 @@ public class TokenController : ControllerBase
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var scopes = request.Scope.GetScopesOrDefault();
|
||||
principal.SetScopes(scopes);
|
||||
principal.SetResources(ResolveResources(scopes));
|
||||
principal.SetResources(await ResolveResourcesAsync(scopes));
|
||||
|
||||
foreach (var claim in principal.Claims)
|
||||
{
|
||||
@ -142,34 +156,28 @@ public class TokenController : ControllerBase
|
||||
return BadRequest("Unsupported grant type.");
|
||||
}
|
||||
|
||||
private IEnumerable<string> ResolveResources(IEnumerable<string> scopes)
|
||||
private async Task<bool> ValidateUserPrincipalAsync(ClaimsPrincipal principal)
|
||||
{
|
||||
var scopeSet = scopes as ISet<string> ?? new HashSet<string>(scopes, StringComparer.Ordinal);
|
||||
if (scopeSet.Count == 0)
|
||||
var subject = principal.GetClaim(OpenIddictConstants.Claims.Subject)
|
||||
?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!Guid.TryParse(subject, out var userId))
|
||||
{
|
||||
return [_memberCenterAudience];
|
||||
return true;
|
||||
}
|
||||
|
||||
var resources = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (scopeSet.Contains("newsletter:send.write") || scopeSet.Contains("newsletter:send.read"))
|
||||
var user = await _userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null || user.DisabledAt.HasValue)
|
||||
{
|
||||
resources.Add(_sendEngineAudience);
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
var tokenSecurityStamp = principal.FindFirst(SecurityStampClaimType)?.Value;
|
||||
return string.IsNullOrWhiteSpace(tokenSecurityStamp)
|
||||
|| string.Equals(tokenSecurityStamp, user.SecurityStamp, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (resources.Count == 0)
|
||||
{
|
||||
resources.Add(_memberCenterAudience);
|
||||
}
|
||||
|
||||
return resources;
|
||||
private async Task<IReadOnlyList<string>> ResolveResourcesAsync(IEnumerable<string> scopes)
|
||||
{
|
||||
return await _authResourceRegistry.ResolveAudiencesAsync(scopes, HttpContext.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
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;
|
||||
@ -9,14 +13,21 @@ 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(UserManager<ApplicationUser> userManager)
|
||||
public UserController(
|
||||
IProfileService profileService,
|
||||
INewsletterService newsletterService,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_profileService = profileService;
|
||||
_newsletterService = newsletterService;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[Authorize(Policy = "ProfileBasicRead")]
|
||||
[HttpGet("profile")]
|
||||
public async Task<IActionResult> Profile()
|
||||
{
|
||||
@ -26,12 +37,280 @@ 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
|
||||
created_at = user.CreatedAt,
|
||||
profile
|
||||
});
|
||||
}
|
||||
|
||||
[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,4 +1,5 @@
|
||||
using OpenIddict.Abstractions;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace MemberCenter.Api.Extensions;
|
||||
|
||||
@ -28,4 +29,11 @@ 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,10 +1,16 @@
|
||||
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;
|
||||
@ -18,6 +24,10 @@ 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")
|
||||
@ -36,6 +46,9 @@ 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();
|
||||
@ -47,6 +60,13 @@ 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 =>
|
||||
{
|
||||
@ -78,12 +98,23 @@ 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");
|
||||
"newsletter:events.write.global",
|
||||
"files:upload.write",
|
||||
"files:download.read",
|
||||
"files:download.delegate",
|
||||
"files:metadata.read",
|
||||
"files:delete");
|
||||
|
||||
options.AddDevelopmentEncryptionCertificate();
|
||||
options.AddDevelopmentSigningCertificate();
|
||||
@ -109,7 +140,16 @@ builder.Services.AddOpenIddict()
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("Admin", policy => policy.RequireRole("admin"));
|
||||
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")));
|
||||
});
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
@ -119,17 +159,55 @@ 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))
|
||||
{
|
||||
@ -148,7 +226,45 @@ 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();
|
||||
@ -195,3 +311,34 @@ 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:5050",
|
||||
"applicationUrl": "http://localhost:7850",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IAccountEmailService
|
||||
{
|
||||
Task SendVerificationEmailAsync(Guid userId, string? fallbackBaseUrl = null);
|
||||
Task SendPasswordResetEmailAsync(Guid userId, string? fallbackBaseUrl = null);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
using MemberCenter.Application.Models.Email;
|
||||
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IAccountEmailTemplateService
|
||||
{
|
||||
EmailTemplate BuildVerificationEmail(string verifyUrl);
|
||||
EmailTemplate BuildPasswordResetEmail(string resetUrl);
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
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);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
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);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IAuditLogWriter
|
||||
{
|
||||
Task WriteAsync(string actorType, Guid? actorId, string action, object payload);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
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);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
|
||||
public interface IEmailSender
|
||||
{
|
||||
Task<int> SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
using MemberCenter.Application.Models.Profile;
|
||||
using MemberCenter.Application.Models.Newsletter;
|
||||
|
||||
namespace MemberCenter.Application.Abstractions;
|
||||
@ -13,4 +14,8 @@ 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);
|
||||
}
|
||||
|
||||
13
src/MemberCenter.Application/Abstractions/IProfileService.cs
Normal file
13
src/MemberCenter.Application/Abstractions/IProfileService.cs
Normal file
@ -0,0 +1,13 @@
|
||||
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,5 +5,7 @@ namespace MemberCenter.Application.Abstractions;
|
||||
public interface ISecuritySettingsService
|
||||
{
|
||||
Task<SecuritySettingsDto> GetAsync();
|
||||
Task SaveAsync(SecuritySettingsDto settings);
|
||||
Task<SmtpSettingsDto> GetSmtpSettingsAsync();
|
||||
Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null);
|
||||
Task<int> SendTestEmailAsync(string toEmail, Guid? actorUserId = null);
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
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";
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
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,4 +5,5 @@ public sealed record AuditLogDto(
|
||||
string ActorType,
|
||||
Guid? ActorId,
|
||||
string Action,
|
||||
string? PayloadJson,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
@ -2,4 +2,16 @@ namespace MemberCenter.Application.Models.Admin;
|
||||
|
||||
public sealed record SecuritySettingsDto(
|
||||
int AccessTokenMinutes,
|
||||
int RefreshTokenDays);
|
||||
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);
|
||||
|
||||
14
src/MemberCenter.Application/Models/Admin/SmtpSettingsDto.cs
Normal file
14
src/MemberCenter.Application/Models/Admin/SmtpSettingsDto.cs
Normal file
@ -0,0 +1,14 @@
|
||||
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);
|
||||
@ -0,0 +1,18 @@
|
||||
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);
|
||||
@ -0,0 +1,6 @@
|
||||
namespace MemberCenter.Application.Models.Email;
|
||||
|
||||
public sealed record EmailTemplate(
|
||||
string Subject,
|
||||
string TextBody,
|
||||
string HtmlBody);
|
||||
@ -0,0 +1,18 @@
|
||||
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);
|
||||
@ -0,0 +1,17 @@
|
||||
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);
|
||||
@ -0,0 +1,19 @@
|
||||
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);
|
||||
@ -0,0 +1,19 @@
|
||||
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);
|
||||
@ -0,0 +1,11 @@
|
||||
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);
|
||||
@ -0,0 +1,10 @@
|
||||
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;
|
||||
}
|
||||
16
src/MemberCenter.Domain/Entities/AuthResource.cs
Normal file
16
src/MemberCenter.Domain/Entities/AuthResource.cs
Normal file
@ -0,0 +1,16 @@
|
||||
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();
|
||||
}
|
||||
13
src/MemberCenter.Domain/Entities/AuthResourceScope.cs
Normal file
13
src/MemberCenter.Domain/Entities/AuthResourceScope.cs
Normal file
@ -0,0 +1,13 @@
|
||||
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; }
|
||||
}
|
||||
18
src/MemberCenter.Domain/Entities/FileAccessDownloadToken.cs
Normal file
18
src/MemberCenter.Domain/Entities/FileAccessDownloadToken.cs
Normal file
@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
25
src/MemberCenter.Domain/Entities/UserAddress.cs
Normal file
25
src/MemberCenter.Domain/Entities/UserAddress.cs
Normal file
@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
21
src/MemberCenter.Domain/Entities/UserProfile.cs
Normal file
21
src/MemberCenter.Domain/Entities/UserProfile.cs
Normal file
@ -0,0 +1,21 @@
|
||||
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,3 +1,4 @@
|
||||
using MemberCenter.Domain.Entities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace MemberCenter.Infrastructure.Identity;
|
||||
@ -8,4 +9,11 @@ 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,12 +6,14 @@
|
||||
</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,12 +1,13 @@
|
||||
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>
|
||||
: IdentityDbContext<ApplicationUser, ApplicationRole, Guid>, IDataProtectionKeyContext
|
||||
{
|
||||
public MemberCenterDbContext(DbContextOptions<MemberCenterDbContext> options)
|
||||
: base(options)
|
||||
@ -16,11 +17,18 @@ 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)
|
||||
{
|
||||
@ -76,6 +84,67 @@ 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");
|
||||
@ -136,6 +205,73 @@ 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
@ -0,0 +1,159 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1215
src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.Designer.cs
generated
Normal file
1215
src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,113 @@
|
||||
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
@ -0,0 +1,78 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1321
src/MemberCenter.Infrastructure/Persistence/Migrations/20260430200729_AddDataProtectionKeys.Designer.cs
generated
Normal file
1321
src/MemberCenter.Infrastructure/Persistence/Migrations/20260430200729_AddDataProtectionKeys.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,36 @@
|
||||
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,6 +54,135 @@ 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")
|
||||
@ -122,6 +251,78 @@ 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")
|
||||
@ -283,6 +484,180 @@ 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")
|
||||
@ -339,6 +714,12 @@ 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)");
|
||||
@ -351,6 +732,12 @@ 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");
|
||||
|
||||
@ -396,6 +783,25 @@ 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")
|
||||
@ -707,6 +1113,17 @@ 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)
|
||||
@ -716,6 +1133,21 @@ 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")
|
||||
@ -754,6 +1186,24 @@ 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)
|
||||
@ -829,6 +1279,11 @@ 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");
|
||||
@ -839,6 +1294,13 @@ 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");
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
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>");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,282 @@
|
||||
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.CreatedAt))
|
||||
.Select(l => new AuditLogDto(l.Id, l.ActorType, l.ActorId, l.Action, l.Payload.RootElement.GetRawText(), l.CreatedAt))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
29
src/MemberCenter.Infrastructure/Services/AuditLogWriter.cs
Normal file
29
src/MemberCenter.Infrastructure/Services/AuditLogWriter.cs
Normal file
@ -0,0 +1,29 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,293 @@
|
||||
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,5 +1,6 @@
|
||||
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;
|
||||
@ -358,6 +359,122 @@ 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);
|
||||
|
||||
370
src/MemberCenter.Infrastructure/Services/ProfileService.cs
Normal file
370
src/MemberCenter.Infrastructure/Services/ProfileService.cs
Normal file
@ -0,0 +1,370 @@
|
||||
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,6 +3,8 @@ 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;
|
||||
|
||||
@ -10,28 +12,177 @@ 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)
|
||||
public SecuritySettingsService(
|
||||
MemberCenterDbContext dbContext,
|
||||
IAuditLogWriter auditLogWriter)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_auditLogWriter = auditLogWriter;
|
||||
}
|
||||
|
||||
public async Task<SecuritySettingsDto> GetAsync()
|
||||
{
|
||||
var access = await GetFlagAsync(AccessTokenKey, 60);
|
||||
var refresh = await GetFlagAsync(RefreshTokenKey, 30);
|
||||
return new SecuritySettingsDto(access, refresh);
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task SaveAsync(SecuritySettingsDto settings)
|
||||
public async Task<SmtpSettingsDto> GetSmtpSettingsAsync()
|
||||
{
|
||||
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);
|
||||
@ -43,6 +194,23 @@ 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);
|
||||
@ -62,4 +230,7 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
|
||||
flag.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalize(string? value) => value?.Trim() ?? string.Empty;
|
||||
|
||||
}
|
||||
|
||||
72
src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs
Normal file
72
src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs
Normal file
@ -0,0 +1,72 @@
|
||||
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,7 +1,9 @@
|
||||
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;
|
||||
@ -9,6 +11,7 @@ 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;
|
||||
@ -54,7 +57,7 @@ var targetMigrationOption = new Option<string?>(
|
||||
name: "--target",
|
||||
description: "Target migration");
|
||||
|
||||
var initCommand = new Command("init", "Initialize database (migrate + seed + admin)");
|
||||
var initCommand = new Command("init", "Initialize database (migrate + seed + superuser)");
|
||||
initCommand.AddOption(connectionStringOption);
|
||||
initCommand.AddOption(appsettingsOption);
|
||||
initCommand.AddOption(noPromptOption);
|
||||
@ -87,19 +90,22 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
|
||||
|
||||
if (!noPrompt)
|
||||
{
|
||||
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
||||
adminPassword ??= PromptSecret("Admin password");
|
||||
adminEmail ??= Prompt("Superuser email", "admin@example.com");
|
||||
adminPassword ??= PromptSecret("Superuser password");
|
||||
}
|
||||
|
||||
adminEmail ??= "admin@example.com";
|
||||
if (string.IsNullOrWhiteSpace(adminPassword))
|
||||
{
|
||||
Console.Error.WriteLine("Admin password is required.");
|
||||
Console.Error.WriteLine("Superuser 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");
|
||||
|
||||
@ -121,9 +127,9 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
|
||||
}
|
||||
}
|
||||
|
||||
if (!await userManager.IsInRoleAsync(admin, "admin"))
|
||||
if (!await userManager.IsInRoleAsync(admin, "superuser"))
|
||||
{
|
||||
await userManager.AddToRoleAsync(admin, "admin");
|
||||
await userManager.AddToRoleAsync(admin, "superuser");
|
||||
}
|
||||
|
||||
await SetInstalledFlagAsync(db);
|
||||
@ -134,7 +140,8 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
|
||||
}
|
||||
}, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption);
|
||||
|
||||
var addAdminCommand = new Command("add-admin", "Add admin user");
|
||||
var addAdminCommand = new Command("add-superuser", "Add or elevate superuser");
|
||||
addAdminCommand.AddAlias("add-admin");
|
||||
addAdminCommand.AddOption(connectionStringOption);
|
||||
addAdminCommand.AddOption(appsettingsOption);
|
||||
addAdminCommand.AddOption(noPromptOption);
|
||||
@ -151,14 +158,14 @@ addAdminCommand.SetHandler(async (string? connectionString, string? appsettings,
|
||||
|
||||
if (!noPrompt)
|
||||
{
|
||||
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
||||
adminPassword ??= PromptSecret("Admin password");
|
||||
adminEmail ??= Prompt("Superuser email", "admin@example.com");
|
||||
adminPassword ??= PromptSecret("Superuser password");
|
||||
}
|
||||
|
||||
adminEmail ??= "admin@example.com";
|
||||
if (string.IsNullOrWhiteSpace(adminPassword))
|
||||
{
|
||||
Console.Error.WriteLine("Admin password is required.");
|
||||
Console.Error.WriteLine("Superuser password is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -167,7 +174,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, "admin");
|
||||
await EnsureRoleAsync(roleManager, "superuser");
|
||||
|
||||
var admin = await userManager.FindByEmailAsync(adminEmail);
|
||||
if (admin is null)
|
||||
@ -187,15 +194,16 @@ addAdminCommand.SetHandler(async (string? connectionString, string? appsettings,
|
||||
}
|
||||
}
|
||||
|
||||
if (!await userManager.IsInRoleAsync(admin, "admin"))
|
||||
if (!await userManager.IsInRoleAsync(admin, "superuser"))
|
||||
{
|
||||
await userManager.AddToRoleAsync(admin, "admin");
|
||||
await userManager.AddToRoleAsync(admin, "superuser");
|
||||
}
|
||||
|
||||
Console.WriteLine("Admin user ready.");
|
||||
Console.WriteLine("Superuser ready.");
|
||||
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
|
||||
|
||||
var resetCommand = new Command("reset-admin-password", "Reset admin password");
|
||||
var resetCommand = new Command("reset-superuser-password", "Reset superuser password");
|
||||
resetCommand.AddAlias("reset-admin-password");
|
||||
resetCommand.AddOption(connectionStringOption);
|
||||
resetCommand.AddOption(appsettingsOption);
|
||||
resetCommand.AddOption(noPromptOption);
|
||||
@ -212,14 +220,14 @@ resetCommand.SetHandler(async (string? connectionString, string? appsettings, bo
|
||||
|
||||
if (!noPrompt)
|
||||
{
|
||||
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
||||
adminEmail ??= Prompt("Superuser email", "admin@example.com");
|
||||
adminPassword ??= PromptSecret("New password");
|
||||
}
|
||||
|
||||
adminEmail ??= "admin@example.com";
|
||||
if (string.IsNullOrWhiteSpace(adminPassword))
|
||||
{
|
||||
Console.Error.WriteLine("Admin password is required.");
|
||||
Console.Error.WriteLine("Superuser password is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -230,7 +238,7 @@ resetCommand.SetHandler(async (string? connectionString, string? appsettings, bo
|
||||
var admin = await userManager.FindByEmailAsync(adminEmail);
|
||||
if (admin is null)
|
||||
{
|
||||
Console.Error.WriteLine("Admin not found.");
|
||||
Console.Error.WriteLine("Superuser not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -264,6 +272,8 @@ 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
|
||||
{
|
||||
@ -274,10 +284,60 @@ 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);
|
||||
|
||||
@ -285,12 +345,22 @@ 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;
|
||||
@ -303,9 +373,42 @@ 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);
|
||||
|
||||
415
src/MemberCenter.TestSite/Controllers/HomeController.cs
Normal file
415
src/MemberCenter.TestSite/Controllers/HomeController.cs
Normal file
@ -0,0 +1,415 @@
|
||||
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('/', '_');
|
||||
}
|
||||
}
|
||||
9
src/MemberCenter.TestSite/MemberCenter.TestSite.csproj
Normal file
9
src/MemberCenter.TestSite/MemberCenter.TestSite.csproj
Normal file
@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
8
src/MemberCenter.TestSite/Models/ErrorViewModel.cs
Normal file
8
src/MemberCenter.TestSite/Models/ErrorViewModel.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace MemberCenter.TestSite.Models;
|
||||
|
||||
public class ErrorViewModel
|
||||
{
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
}
|
||||
14
src/MemberCenter.TestSite/Models/MemberCenterTestOptions.cs
Normal file
14
src/MemberCenter.TestSite/Models/MemberCenterTestOptions.cs
Normal file
@ -0,0 +1,14 @@
|
||||
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";
|
||||
}
|
||||
11
src/MemberCenter.TestSite/Models/TestDashboardViewModel.cs
Normal file
11
src/MemberCenter.TestSite/Models/TestDashboardViewModel.cs
Normal file
@ -0,0 +1,11 @@
|
||||
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; }
|
||||
}
|
||||
32
src/MemberCenter.TestSite/Program.cs
Normal file
32
src/MemberCenter.TestSite/Program.cs
Normal file
@ -0,0 +1,32 @@
|
||||
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();
|
||||
38
src/MemberCenter.TestSite/Properties/launchSettings.json
Normal file
38
src/MemberCenter.TestSite/Properties/launchSettings.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/MemberCenter.TestSite/Views/Home/Index.cshtml
Normal file
121
src/MemberCenter.TestSite/Views/Home/Index.cshtml
Normal file
@ -0,0 +1,121 @@
|
||||
@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>
|
||||
6
src/MemberCenter.TestSite/Views/Home/Privacy.cshtml
Normal file
6
src/MemberCenter.TestSite/Views/Home/Privacy.cshtml
Normal file
@ -0,0 +1,6 @@
|
||||
@{
|
||||
ViewData["Title"] = "Privacy Policy";
|
||||
}
|
||||
<h1>@ViewData["Title"]</h1>
|
||||
|
||||
<p>Use this page to detail your site's privacy policy.</p>
|
||||
25
src/MemberCenter.TestSite/Views/Shared/Error.cshtml
Normal file
25
src/MemberCenter.TestSite/Views/Shared/Error.cshtml
Normal file
@ -0,0 +1,25 @@
|
||||
@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>
|
||||
46
src/MemberCenter.TestSite/Views/Shared/_Layout.cshtml
Normal file
46
src/MemberCenter.TestSite/Views/Shared/_Layout.cshtml
Normal file
@ -0,0 +1,46 @@
|
||||
<!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>
|
||||
48
src/MemberCenter.TestSite/Views/Shared/_Layout.cshtml.css
Normal file
48
src/MemberCenter.TestSite/Views/Shared/_Layout.cshtml.css
Normal file
@ -0,0 +1,48 @@
|
||||
/* 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;
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
|
||||
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
|
||||
3
src/MemberCenter.TestSite/Views/_ViewImports.cshtml
Normal file
3
src/MemberCenter.TestSite/Views/_ViewImports.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@using MemberCenter.TestSite
|
||||
@using MemberCenter.TestSite.Models
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
3
src/MemberCenter.TestSite/Views/_ViewStart.cshtml
Normal file
3
src/MemberCenter.TestSite/Views/_ViewStart.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
20
src/MemberCenter.TestSite/appsettings.json
Normal file
20
src/MemberCenter.TestSite/appsettings.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"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": "*"
|
||||
}
|
||||
161
src/MemberCenter.TestSite/wwwroot/css/site.css
Normal file
161
src/MemberCenter.TestSite/wwwroot/css/site.css
Normal file
@ -0,0 +1,161 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
BIN
src/MemberCenter.TestSite/wwwroot/favicon.ico
Normal file
BIN
src/MemberCenter.TestSite/wwwroot/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
4
src/MemberCenter.TestSite/wwwroot/js/site.js
Normal file
4
src/MemberCenter.TestSite/wwwroot/js/site.js
Normal file
@ -0,0 +1,4 @@
|
||||
// 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.
|
||||
22
src/MemberCenter.TestSite/wwwroot/lib/bootstrap/LICENSE
Normal file
22
src/MemberCenter.TestSite/wwwroot/lib/bootstrap/LICENSE
Normal file
@ -0,0 +1,22 @@
|
||||
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.
|
||||
4997
src/MemberCenter.TestSite/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
vendored
Normal file
4997
src/MemberCenter.TestSite/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
vendored
Normal file
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