Merge branch 'develop'

This commit is contained in:
warrenchen 2026-07-17 18:52:13 +09:00
commit a2a43250df
320 changed files with 99236 additions and 1448 deletions

View File

@ -1,8 +1,69 @@
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
Auth__Issuer=http://localhost:7850/ Auth__Issuer=http://localhost:7850/
Auth__WebLoginUrl=http://localhost:5080/account/login
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__AllowInternalHttpTokenEndpoint=false
# Optional PFX file overrides. Production normally reads app-managed certificates provisioned by installer into DB.
Auth__Certificates__Signing__Path=
Auth__Certificates__Signing__Password=
Auth__Certificates__Encryption__Path=
Auth__Certificates__Encryption__Password=
DataProtection__Certificate__Path=
DataProtection__Certificate__Password=
# Optional during rotation: repeat with __Previous__1__, etc.
Auth__Certificates__Signing__Previous__0__Path=
Auth__Certificates__Signing__Previous__0__Password=
Auth__Certificates__Encryption__Previous__0__Path=
Auth__Certificates__Encryption__Previous__0__Password=
DataProtection__Certificate__Previous__0__Path=
DataProtection__Certificate__Previous__0__Password=
Certificates__ExpirationWarningDays=30
ReverseProxy__TrustForwardedHeaders=false
ReverseProxy__KnownProxies=
ReverseProxy__KnownNetworks=
ReverseProxy__ForwardLimit=1
IdentitySecurity__Password__RequiredLength=8
IdentitySecurity__Password__RequireDigit=true
IdentitySecurity__Password__RequireLowercase=true
IdentitySecurity__Password__RequireUppercase=true
IdentitySecurity__Password__RequireNonAlphanumeric=false
IdentitySecurity__Lockout__AllowedForNewUsers=true
IdentitySecurity__Lockout__MaxFailedAccessAttempts=5
IdentitySecurity__Lockout__DefaultLockoutMinutes=15
RateLimits__Web__AuthLogin__PermitLimit=10
RateLimits__Web__AuthLogin__WindowSeconds=300
RateLimits__Web__AuthRegister__PermitLimit=5
RateLimits__Web__AuthRegister__WindowSeconds=900
RateLimits__Web__AuthRecovery__PermitLimit=5
RateLimits__Web__AuthRecovery__WindowSeconds=900
RateLimits__Web__AuthTokenConsumption__PermitLimit=10
RateLimits__Web__AuthTokenConsumption__WindowSeconds=600
RateLimits__Api__AuthRegister__PermitLimit=5
RateLimits__Api__AuthRegister__WindowSeconds=900
RateLimits__Api__AuthRecovery__PermitLimit=5
RateLimits__Api__AuthRecovery__WindowSeconds=900
RateLimits__Api__AuthTokenConsumption__PermitLimit=10
RateLimits__Api__AuthTokenConsumption__WindowSeconds=600
RateLimits__Api__NewsletterSubscribe__PermitLimit=20
RateLimits__Api__NewsletterSubscribe__WindowSeconds=600
RateLimits__Api__NewsletterUnsubscribeToken__PermitLimit=10
RateLimits__Api__NewsletterUnsubscribeToken__WindowSeconds=600
RateLimits__Api__NewsletterOneClickToken__PermitLimit=60
RateLimits__Api__NewsletterOneClickToken__WindowSeconds=60
RateLimits__Api__OAuthToken__PermitLimit=30
RateLimits__Api__OAuthToken__WindowSeconds=60
NewsletterTokens__ConfirmTokenLifetimeDays=7
NewsletterTokens__UnsubscribeTokenLifetimeDays=7
NewsletterTokens__OneClickTokenLifetimeDays=7
NewsletterTokens__OneClickBatchSizeLimit=1000
FileAccessTokens__DefaultLifetimeSeconds=300
FileAccessTokens__MinimumLifetimeSeconds=30
FileAccessTokens__MaximumLifetimeSeconds=900
Auth__MemberCenterAudience=member_center_api Auth__MemberCenterAudience=member_center_api
Auth__SendEngineAudience=send_engine_api Auth__SendEngineAudience=send_engine_api
SendEngine__BaseUrl=http://localhost:6060 SendEngine__BaseUrl=http://localhost:6060
SendEngine__SubscriptionEventsPath=/webhooks/subscriptions
SendEngine__WebhookSecret=change-me SendEngine__WebhookSecret=change-me
Testing__DisableSubscriptionDryRunNoDb=false Testing__DisableSubscriptionDryRunNoDb=false

View File

@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Installer", "s
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Web", "src\MemberCenter.Web\MemberCenter.Web.csproj", "{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Web", "src\MemberCenter.Web\MemberCenter.Web.csproj", "{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.TestSite", "src\MemberCenter.TestSite\MemberCenter.TestSite.csproj", "{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{6199566B-62FC-4EBF-9BD3-44B69FDD5E8A}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{6199566B-62FC-4EBF-9BD3-44B69FDD5E8A}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Security.Tests", "tests\MemberCenter.Security.Tests\MemberCenter.Security.Tests.csproj", "{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Security.Tests", "tests\MemberCenter.Security.Tests\MemberCenter.Security.Tests.csproj", "{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}"
@ -26,6 +28,9 @@ Global
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7733733D-22EB-431D-A8AA-833486C3E0E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7733733D-22EB-431D-A8AA-833486C3E0E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7733733D-22EB-431D-A8AA-833486C3E0E2}.Debug|Any CPU.Build.0 = Debug|Any CPU {7733733D-22EB-431D-A8AA-833486C3E0E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
@ -51,9 +56,14 @@ Global
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Debug|Any CPU.Build.0 = Debug|Any CPU {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.ActiveCfg = Release|Any CPU
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Release|Any CPU.Build.0 = Release|Any CPU {91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection {ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
GlobalSection(SolutionProperties) = preSolution {ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
HideSolutionNode = FALSE {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
{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{7733733D-22EB-431D-A8AA-833486C3E0E2} = {150D3A20-BF61-4012-BD40-05D408749112} {7733733D-22EB-431D-A8AA-833486C3E0E2} = {150D3A20-BF61-4012-BD40-05D408749112}
@ -62,6 +72,7 @@ Global
{051ECE48-E49B-4E42-BE08-6E9AAB7262BC} = {150D3A20-BF61-4012-BD40-05D408749112} {051ECE48-E49B-4E42-BE08-6E9AAB7262BC} = {150D3A20-BF61-4012-BD40-05D408749112}
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2} = {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} {91DF0CEA-698F-4DF5-A44C-89AB38AA2561} = {150D3A20-BF61-4012-BD40-05D408749112}
{ABA4996C-B48F-444C-BEF2-83BDED5D74B2} = {150D3A20-BF61-4012-BD40-05D408749112}
{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE} = {6199566B-62FC-4EBF-9BD3-44B69FDD5E8A} {33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE} = {6199566B-62FC-4EBF-9BD3-44B69FDD5E8A}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@ -44,13 +44,17 @@
- `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信) - `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信)
- `docs/TECH_STACK.md`:技術棧與選型 - `docs/TECH_STACK.md`:技術棧與選型
- `docs/INSTALL.md`:安裝、初始化與維運指令 - `docs/INSTALL.md`:安裝、初始化與維運指令
- `docs/MEMBER_UPGRADE_PLAN.md`會員中心下一階段升級規劃設定畫面、SMTP、Email 驗證、忘記密碼、角色分級)
- `docs/ADMIN_AUTHORIZATION.md`:後台 Role / Permission 權限模型與管理原則
- `docs/TEST_SITE.md`手動整合測試站說明redirect login、service token、會員 API happy path
## 專案結構 ## 專案結構
```text ```text
member_center/ member_center/
├── src/ ├── src/
│ ├── MemberCenter.Api/ # REST APIOAuth/OIDC、訂閱、管理 API │ ├── MemberCenter.Api/ # REST APIOAuth/OIDC、訂閱、管理 API
│ ├── MemberCenter.Web/ # MVC Web UI會員與後台頁面 │ ├── MemberCenter.Web/ # MVC Web UIclient-first 會員介面 + Areas/Admin 管理介面)
│ ├── MemberCenter.TestSite/ # 手動整合測試站API / redirect login happy path
│ ├── MemberCenter.Installer/ # 安裝與初始化 CLImigrate/init/admin │ ├── MemberCenter.Installer/ # 安裝與初始化 CLImigrate/init/admin
│ ├── MemberCenter.Application/ # 應用層介面與 DTO │ ├── MemberCenter.Application/ # 應用層介面與 DTO
│ ├── MemberCenter.Infrastructure/# EF Core、Identity、OpenIddict、服務實作 │ ├── MemberCenter.Infrastructure/# EF Core、Identity、OpenIddict、服務實作
@ -66,3 +70,11 @@ member_center/
- 事件系統選擇Kafka/RabbitMQ/SNS+SQS - 事件系統選擇Kafka/RabbitMQ/SNS+SQS
- 取消訂閱的 UI 形式(純一鍵或提供偏好) - 取消訂閱的 UI 形式(純一鍵或提供偏好)
- GDPR/CCPA 資料匯出與刪除是否納入第一期 - 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` 事件。

View File

@ -0,0 +1,90 @@
# Admin Authorization
## Purpose
The admin authorization model separates a user's organizational role from the
individual admin capabilities granted to that role. The schema supports multiple
roles even though the initial non-superuser role is only `admin`.
## Core Rules
1. `superuser` is a hard-coded emergency and governance role.
- It does not depend on database permission mappings.
- It bypasses all admin permission checks.
- Creating a replacement superuser or resetting its password remains an
installer responsibility.
- Superuser-only account governance actions continue to require the
`Superuser` authorization policy.
2. Normal admin access uses Role-Based Access Control.
- A user may have one or more Identity roles through `user_roles`.
- A role may have one or more admin permissions through
`admin_role_permissions`.
- A user's effective permissions are the union of permissions assigned to all
of the user's roles.
3. Roles and role-permission mappings are database data.
- Existing ASP.NET Core Identity `roles` and `user_roles` tables remain the
source of role membership.
- `admin_permissions` stores the known permission catalog.
- `admin_role_permissions` maps any Identity role to any admin permission.
- Adding roles such as `support`, `auditor`, or `security_admin` does not
require another schema change.
4. Permission definitions are owned by application code.
- Permission keys are declared in `AdminPermissions`.
- Startup and installer seeding synchronize those known definitions into the
database.
- The database decides which roles receive known permissions; it must not be
used to invent capabilities that have no application implementation.
5. Permissions are action-oriented.
- Controllers require a module permission such as `admin.tenants`.
- Actions also require a capability permission such as
`admin.tenants.create` or `admin.tenants.delete`.
- Operations with materially different authorization boundaries must be
separate actions and separate permission keys.
6. Server-side checks are authoritative.
- Admin controllers retain `[Authorize(Policy = "Admin")]` as the outer admin
boundary.
- Controllers and actions use `AdminPermissionAttribute` for database-backed
permission checks.
- Non-admin access to `/admin/*` continues to return HTTP 404.
- Missing action permissions also return HTTP 404 so inaccessible admin
capabilities are not exposed.
7. Navigation and operation links use the same permission catalog.
- Admin menus reference the same `AdminPermissions` constants used by the
corresponding controller actions.
- Buttons and links are hidden when the current user lacks the target action
permission.
- UI visibility is only a usability measure; direct requests are always
protected by controller/action checks.
## Initial Mapping
The initial `admin` role receives every known permission except permissions
marked as superuser-only. This preserves current behavior while allowing the
mapping to be divided among more roles later.
The initial superuser-only permissions are:
- `admin.accounts.set_admin`
- `admin.accounts.set_disabled`
- `admin.accounts.reset_password`
No role or permission maintenance UI is included yet. Until one is introduced,
the permission catalog is updated in code and role-permission mappings may be
managed through controlled database changes or future installer commands.
## Adding an Admin Capability
1. Add a constant and definition to `AdminPermissions`.
2. Apply the module permission to the controller.
3. Apply the capability permission to every relevant action.
4. Use the same action permission for related menu items, links, and buttons.
5. Add or update role-permission mappings through the approved seed or
administrative workflow.
6. Verify direct access, navigation visibility, `admin`, and `superuser`
behavior.

View 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

135
docs/CONFIGURATION.md Normal file
View File

@ -0,0 +1,135 @@
# Member Center 執行參數
所有 .NET configuration key 均可改用環境變數,將 `:` 改為 `__`。例如 `RateLimits:Api:OAuthToken:PermitLimit` 對應 `RateLimits__Api__OAuthToken__PermitLimit`。Production secret 必須由 Secrets Manager / Parameter Store 注入。
## 基礎啟動
| Key | 預設值 | 說明 |
|---|---:|---|
| `ASPNETCORE_ENVIRONMENT` / `DOTNET_ENVIRONMENT` | `Production` | `Development` 才讀 repo root `.env`,並允許開發憑證/本機 DB fallback。 |
| `ConnectionStrings:Default` | Development: localhost PostgreSQL | API/Web 非 Development 必填。 |
| `MEMBERCENTER_CONNECTION` | 無 | Installer / design-time DbContext 備用 connection string。 |
| `PathBase` | 空 | API 子路徑,例如 `/member-center`。 |
| `AllowedHosts` | Web: `*` | ASP.NET Core host filteringProduction 建議填實際 host。 |
| `Logging:LogLevel:*` | Default=`Information` | 標準 .NET logging level。 |
## Identity
| Key | 預設值 | 規則 |
|---|---:|---|
| `IdentitySecurity:Password:RequiredLength` | `8` | 不得小於 8。 |
| `IdentitySecurity:Password:RequireDigit` | `true` | 至少一個數字。 |
| `IdentitySecurity:Password:RequireLowercase` | `true` | 至少一個小寫字母。 |
| `IdentitySecurity:Password:RequireUppercase` | `true` | 至少一個大寫字母。 |
| `IdentitySecurity:Password:RequireNonAlphanumeric` | `false` | 暫不要求特殊字元。 |
| `IdentitySecurity:Lockout:AllowedForNewUsers` | `true` | 新帳號啟用 lockout。 |
| `IdentitySecurity:Lockout:MaxFailedAccessAttempts` | `5` | 必須大於 0。 |
| `IdentitySecurity:Lockout:DefaultLockoutMinutes` | `15` | 必須大於 0。 |
API、Web 與 Installer 共用上述規則。
## Rate limit
每個 prefix 都有 `PermitLimit``WindowSeconds`,兩者必須大於 0。限制為單 instance 記憶體計數;多 instance Production 仍需 AWS WAF。
| Prefix | Permit | 秒 | 適用端點 |
|---|---:|---:|---|
| `RateLimits:Web:AuthLogin` | 10 | 300 | Web login |
| `RateLimits:Web:AuthRegister` | 5 | 900 | Web register |
| `RateLimits:Web:AuthRecovery` | 5 | 900 | Web forgot/resend |
| `RateLimits:Web:AuthTokenConsumption` | 10 | 600 | Web reset/verify |
| `RateLimits:Api:AuthRegister` | 5 | 900 | API register |
| `RateLimits:Api:AuthRecovery` | 5 | 900 | API forgot/resend |
| `RateLimits:Api:AuthTokenConsumption` | 10 | 600 | API reset/verify |
| `RateLimits:Api:NewsletterSubscribe` | 20 | 600 | Newsletter subscribe |
| `RateLimits:Api:NewsletterUnsubscribeToken` | 10 | 600 | Unsubscribe token issuance |
| `RateLimits:Api:NewsletterOneClickToken` | 60 | 60 | One-click single/batch |
| `RateLimits:Api:OAuthToken` | 30 | 60 | `/oauth/token` |
## Newsletter / File Access token
| Key | 預設值 | 說明 |
|---|---:|---|
| `NewsletterTokens:ConfirmTokenLifetimeDays` | `7` | 訂閱確認 token。 |
| `NewsletterTokens:UnsubscribeTokenLifetimeDays` | `7` | 一般退訂 token。 |
| `NewsletterTokens:OneClickTokenLifetimeDays` | `7` | One-click token。 |
| `NewsletterTokens:OneClickBatchSizeLimit` | `1000` | 批次 subscriber id 上限。 |
| `FileAccessTokens:DefaultLifetimeSeconds` | `300` | Delegated download token 預設時效。 |
| `FileAccessTokens:MinimumLifetimeSeconds` | `30` | 呼叫端最短可要求時效。 |
| `FileAccessTokens:MaximumLifetimeSeconds` | `900` | 呼叫端最長可要求時效。 |
Newsletter 值皆須大於 0File Access 必須符合 `0 < minimum <= default <= maximum`
## OAuth / OIDC
| Key | 預設值 | 說明 |
|---|---:|---|
| `Auth:Issuer` | Development 可空 | Production 必填 HTTPS。 |
| `Auth:WebLoginUrl` | `/account/login` | Authorize 未登入時的 Web login URL。 |
| `Auth:AllowedLoginReturnUrlPrefixes` | 空 | 逗號分隔 login allowlist。 |
| `Auth:AllowedLogoutReturnUrlPrefixes` | 空 | 逗號分隔 logout allowlist。 |
| `Auth:AllowInternalHttpTokenEndpoint` | `false` | 允許 VPC 私有 HTTP OAuth endpointissuer 仍為 HTTPS。 |
| `Auth:Resources:MemberCenter:Audience` | `member_center_api` | Member Center audience seed。 |
| `Auth:Resources:SendEngine:Audience` | `send_engine_api` | Send Engine audience seed。 |
| `Auth:Resources:FileAccess:Audience` | `file_access_api` | File Access audience seed。 |
| `Auth:MemberCenterAudience` / `Auth:SendEngineAudience` | 無 | 舊版相容 seed。 |
OAuth usage/scope mapping 的正式來源為 DB registryaudience key 只作 seed / 相容來源。
完整現有 scope catalog 與 usage mapping 請見 `docs/SCOPES.md`
## 憑證與 Data Protection
| Key | 預設值 | 說明 |
|---|---:|---|
| DB `system_flags.certificates:openiddict:signing` | installer 產生 | Production API JWT signing PFX。 |
| DB `system_flags.certificates:openiddict:encryption` | installer 產生 | Production API OpenIddict encryption PFX。 |
| DB `system_flags.certificates:data-protection` | installer 產生 | Production API/Web 共用 Data Protection PFX。 |
| `Auth:Certificates:Signing:Path/Password` | 無 | 選填;覆寫 DB signing PFX。 |
| `Auth:Certificates:Encryption:Path/Password` | 無 | 選填;覆寫 DB encryption PFX。 |
| `DataProtection:Certificate:Path/Password` | 無 | 選填;覆寫 DB Data Protection PFX。 |
| `*:Previous:0:Path/Password` | 無 | 選填;檔案型前代憑證,可增加索引。 |
| `Certificates:ExpirationWarningDays` | `30` | 到期 warning有效範圍 1180。 |
## Reverse proxy
| Key | 預設值 | 說明 |
|---|---:|---|
| `ReverseProxy:TrustForwardedHeaders` | `false` | 設為 `true` 時信任 forwarded headers不要求 `KnownProxies` / `KnownNetworks`;僅可在 app inbound 已由 Security Group / 私有網路限制為可信 proxy 時使用。 |
| `ReverseProxy:KnownProxies` | 空 | 逗號分隔可信 proxy IP。 |
| `ReverseProxy:KnownNetworks` | 空 | 逗號分隔可信 CIDR禁止 `/0`。 |
| `ReverseProxy:ForwardLimit` | `1` | Proxy hop限制 15。 |
`ReverseProxy:TrustForwardedHeaders=true` 會接受 `X-Forwarded-For``X-Forwarded-Proto``X-Forwarded-Host`。此模式適合 AWS ALB / managed reverse proxy private IP 會變動,但 app security group 已只允許該 proxy 連入的環境。
未啟用 `TrustForwardedHeaders` 時,必須設定 `KnownProxies``KnownNetworks` 才會接受 forwarded headersallowlist 都為空時完全忽略 forwarded headers。
## 外部整合與測試旗標
| Key | 預設值 | 說明 |
|---|---:|---|
| `Authentication:Google:ClientId/ClientSecret` | 無 | 兩者都有才啟用 Google login。 |
| `SendEngine:BaseUrl` | 無 | Send Engine base URL。 |
| `SendEngine:SubscriptionEventsPath` | `/webhooks/subscriptions` | Subscription webhook path。 |
| `SendEngine:WebhookSecret` | 無 | Webhook HMAC secret。 |
| `Testing:DisableSubscriptionDryRunNoDb` | `false` | 僅測試使用Production 必須 false。 |
## 後台 DB 設定
`/admin/security` 儲存在 `system_flags`
| 設定 | 預設值 / 限制 |
|---|---|
| Access token lifetime | 60 分鐘51440。 |
| Refresh token lifetime | 30 天1365。 |
| Public base URL | 空Production 應為 HTTPS。 |
| SMTP relay / port | host 空port 587。 |
| SMTP TLS / SSL | TLS=true、SSL=false不得同時啟用。 |
| SMTP timeout | 15 秒。 |
| SMTP username/password | 空password 加密保存。 |
| Sender name / email | `Member Center` / 空。 |
## TestSite 與 Installer
TestSite 使用 `MemberCenter:*``ApiBaseUrl``WebBaseUrl``WebLoginClientId``WebLoginRedirectPath``WebLogoutRedirectPath``WebLoginScopes``ServiceClientId``ServiceClientSecret``ServiceScopes`。Secret 不得寫入 `appsettings.json`
Installer 共通參數:`--connection-string/-c``--appsettings``--no-prompt``--verbose`;另有 `--force``--admin-email``--admin-password``--admin-display-name``--target`。完整命令以 `dotnet run --project src/MemberCenter.Installer -- --help` 為準。

View File

@ -12,11 +12,13 @@
## 2. 架構原則 ## 2. 架構原則
- OAuth2 + OIDCAuthorization Code + PKCE - OAuth2 + OIDCAuthorization Code + PKCE
- 會員中心只管理 Email 與訂閱狀態 - 會員中心只管理 Email 與訂閱狀態
- 檔案存取授權沿用 OAuth2/JWT/JWKS並以 scope + claim 做資源邊界控制
- 對外資料 API 以 service API 為主,授權完全由 scope 控制
- Double Opt-in - Double Opt-in
- 各站自行設計 UI主要走 API少數狀況使用 redirect - 各站自行設計 UI主要走 API少數狀況使用 redirect
- 多租戶為邏輯隔離,但會員資料跨站共享 - 多租戶為邏輯隔離,但會員資料跨站共享
- 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作 - 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作
- 訂閱狀態同步目前採 webhookevent payloadqueue 為後續可選擴充 - 訂閱狀態同步採 webhookevent payloadqueue 可作為未來擴充選項
- PostgreSQL - PostgreSQL
- 實作C# .NET Core + MVC + OpenIddict - 實作C# .NET Core + MVC + OpenIddict
@ -28,8 +30,10 @@
- 站點後台:可管理站點資訊、訂閱清單、會員基本資料 - 站點後台:可管理站點資訊、訂閱清單、會員基本資料
## 4. 核心模組 ## 4. 核心模組
- Identity Service註冊、登入、密碼重設、Email 驗證 - Identity Service註冊、登入、修改密碼、密碼重設、Email 驗證
- OAuth2/OIDC Service授權流程、token 發放、ID Token - OAuth2/OIDC Service授權流程、token 發放、ID Token
- Auth Resource Registry管理外部服務 resource / audience / scopes / client usage 對應
- Profile Service會員基本資料、聯絡方式、公司資料、地址簿
- Subscription Service訂閱/退訂/偏好管理 - Subscription Service訂閱/退訂/偏好管理
- Admin Console租戶與清單管理 - Admin Console租戶與清單管理
- Mailer Integration驗證信/退訂信/確認信的發送介面(外部系統) - Mailer Integration驗證信/退訂信/確認信的發送介面(外部系統)
@ -39,11 +43,26 @@
- tenants - tenants
- id, name, domains, status, created_at - id, name, domains, status, created_at
- users (ASP.NET Core Identity) - 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) - roles / user_roles (Identity)
- id, name, created_at - id, name, created_at
- OpenIddictApplications - 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 - OpenIddictAuthorizations
- id, application_id, status, subject, type, scopes - id, application_id, status, subject, type, scopes
- OpenIddictTokens - OpenIddictTokens
@ -60,6 +79,8 @@
- id, email, tenant_id, token_hash, purpose, expires_at, consumed_at - id, email, tenant_id, token_hash, purpose, expires_at, consumed_at
- unsubscribe_tokens - unsubscribe_tokens
- id, subscription_id, token_hash, expires_at, consumed_at - 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 - audit_logs
- id, actor_type, actor_id, action, payload, created_at - id, actor_type, actor_id, action, payload, created_at
- system_flags - system_flags
@ -70,14 +91,27 @@
- 使用者註冊時,如 email 存在訂閱紀錄,補上 user_id - 使用者註冊時,如 email 存在訂閱紀錄,補上 user_id
- 單一清單退訂unsubscribe token 綁定 subscription_id - 單一清單退訂unsubscribe token 綁定 subscription_id
- blacklist 記錄於 email_blacklist全租戶共用 - blacklist 記錄於 email_blacklist全租戶共用
- email 為會員與訂閱領域的對外主 key不提供改 email 流程
## 6. 核心流程 ## 6. 核心流程
### 6.1 OAuth2/OIDC Redirect 登入Authorization Code + PKCE ### 6.1 OAuth2/OIDC Redirect 登入Authorization Code + PKCE
1) 站點導向 `/oauth/authorize`,帶 `client_id`, `redirect_uri`, `code_challenge`, `scope=openid email` 狀態:已支援 `usage=web_login`
2) 使用者於會員中心登入
3) 成功後導回 `redirect_uri` 並附 `code` 1) 站點建立 OAuth client`usage=web_login`,設定 `redirect_uris`
4) 站點以 `code` + `code_verifier``/oauth/token` 換取 token + `id_token` 2) 站點導向 `/oauth/authorize`,帶 `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method=S256`, `response_type=code`, `scope=openid email profile`
3) 若使用者尚未登入,`/oauth/authorize` 會導向會員中心 Web login登入後回到原 authorize request
4) 成功後導回 `redirect_uri` 並附 `code`
5) 站點以 `code` + `code_verifier``/oauth/token` 換取 token
實作註記:
- API 與 Web 需共用 DataProtection application name `MemberCenter`,使 API authorize endpoint 可讀取 Web login cookie。
- 若 API 與 Web 位於不同子網域,需設定 `Auth:CookieDomain`,例如 `.example.com`
- 若 API 與 Web 不同 originWeb login 僅允許導回 `Auth:Issuer``Auth:AllowedLoginReturnUrlPrefixes` 內的 return URL。
- Login / Logout return URL allowlist 分開驗證,採 URI origin 與 path segment prefix 比對,不使用原始字串 `StartsWith`
- API 可用 `Auth:WebLoginUrl` 指定登入頁位置;預設為 `/account/login`
- `web_login` 可使用 public client + PKCE不要求 client secret。
- `web_login` client 可使用 `openid email profile` 與 current-user `profile:*` scopes這些 scopes 僅能操作 access token subject 自己的資料。
### 6.2 OAuth2 API 使用(站點自行 UI ### 6.2 OAuth2 API 使用(站點自行 UI
1) 站點以 API 驗證使用者登入(會員中心提供 login API 1) 站點以 API 驗證使用者登入(會員中心提供 login API
@ -124,12 +158,119 @@
3) 將 `user_id` 補上並保留偏好 3) 將 `user_id` 補上並保留偏好
4) 可選:發出事件 `subscription.linked_to_user` 4) 可選:發出事件 `subscription.linked_to_user`
### 6.8 已登入修改密碼
1) 使用者登入會員中心
2) 進入 change password 頁面
3) 提交 `current_password + new_password`
4) 系統驗證目前密碼正確後更新 password hash
5) 更新成功後刷新目前 session
### 6.8b 會員基本資料維護
1) 使用者登入會員中心
2) 進入 profile 頁
3) 讀取基本資料姓、名、nick name、電話、生日、性別、公司、部門、職稱、統編、remark 等)
4) 更新後寫入 `user_profiles`
5) 記錄 audit log
### 6.8c 地址簿管理
1) 使用者登入會員中心
2) 進入地址簿頁面
3) 新增、編輯或刪除地址
4) 系統驗證地址資料歸屬目前 user
5) 若設定預設地址,需確保同用途只有一筆預設值
6) 若只剩最後一筆地址,不允許刪除
7) 記錄 audit log
### 6.8d 已登入會員管理自己的訂閱
1) 使用者登入會員中心
2) 系統依 `user_id` 讀取已綁定的電子報訂閱
3) 顯示可管理的訂閱清單
4) 使用者可直接取消訂閱
5) 系統驗證訂閱歸屬目前 user 後更新狀態
6) 發送 `subscription.unsubscribed` 事件
7) 不需再次經過 email token 驗證
8) 已綁定 `user_id` 後仍保留 `list_id + email` 的 public 訂閱 / 退訂入口
### 6.9 檔案存取授權File Access
1) Upload 採 S2S
- `A service``client_credentials` 向 Member Center 取 token
- token 帶 file upload 專用 scope
- `A service` 帶 token 呼叫 access agent / file space
- access agent 以 JWKS 驗簽 JWT並驗 `iss/aud/exp/scope/tenant_id`
2) Download 採 delegated short-lived token
- client 向 `A service` 要求下載
- `A service` 以自己的商業邏輯驗證 Member Center user 身份與檔案權限
- `A service``files:download.delegate` 向 Member Center 申請短效 download token
- client 帶短效 token 直接向 access agent / file space 請求檔案
- access agent 以 `files:download.read` 回打 Member Center 驗證 token 與實際 GET request 邊界一致後放行
規則:
- 不直接將一般 S2S access token 暴露給 client 作為下載 token
- download token 應至少帶:
- `tenant_id`
- `user_id`
- `file_id``object_key`
- `method=GET`
- 短效 `exp`
- 建議帶 `jti`
- upload token 應至少帶:
- `aud=file_access_api`
- `scope=files:upload.write`
- `tenant_id`
- access agent 應驗:
- Member Center validation endpoint 回傳 `active=true`
- `tenant_id`
- 檔案識別與 method 是否與 token 一致
### 6.10 外部資源授權抽象Audience / Resource Registry
目的:
- 統一 Send Engine、File Access 與未來其他外部服務的 token 發放規則
- 避免每新增一個服務就新增一組 `Auth__XAudience` 與對應程式分支
共通原則:
- Member Center 為 token issuer 與 JWKS 提供者
- 外部服務皆以 `iss/aud/exp/scope/tenant_id` 驗證 token
- `aud` 不直接以程式硬編碼,而是由 resource registry 決定
resource registry 至少需定義:
- `resource_name`
- `audience`
- `allowed_scopes`
- `allowed_client_usages`
- `requires_tenant`
- `allows_delegated_token`
建議初始資源:
- `member_center_api`
- scopes`openid``email``profile``newsletter:list.read``newsletter:subscriptions.read``newsletter:subscriptions.write``newsletter:events.write``newsletter:events.write.global``profile:basic.read``profile:basic.write``profile:addresses.read``profile:addresses.write``profile:subscriptions.read``profile:subscriptions.write`
- usages`tenant_api``platform_service`、互動式登入 client
- `send_engine_api`
- scopes`newsletter:send.write``newsletter:send.read`
- usages`send_api`
- `file_access_api`
- scopes`files:upload.write``files:download.read``files:download.delegate``files:delete``files:metadata.read`
- usages`file_api`
設計原則:
- resource registry 由 DB 與管理 UI 管理非敏感欄位
- `TokenController` 依 scope 與 usage 對照 resource registry 計算 resources / audiences
- delegated download token 由 Member Center 簽發與線上驗證;一般 S2S token 仍由 resource registry 決定 audience
- 對外資料讀寫授權完全由 scope 決定,只要 client 被授權該 scope 即可存取對應能力
目前實作狀態:
- DB registry 第一版已加入 `auth_resources``auth_resource_scopes``auth_client_usage_permissions`
- 預設 seed 已包含 `member_center_api``send_engine_api``file_access_api`
- OAuth client usage-scope matrix 已由 DB 驅動,包含 `file_api`
- Authorization Code 與 Client Credentials granted scopes 都由 OAuth application `usage` mapping 固定核發
- Usage mapping seed 採完整集合同步,會停用已從預設 mapping 移除的 scopes
- resource registry 管理 UI 仍待補
- delegated download token issuing / validation 已由 Member Center API 負責File Access agent 以 validation endpoint 確認 client GET request 帶來的 token 是否有效
## 7. API 介面(草案) ## 7. API 介面(草案)
- GET `/oauth/authorize` - GET `/oauth/authorize`
- POST `/oauth/token` - POST `/oauth/token`
- GET `/.well-known/openid-configuration` - GET `/.well-known/openid-configuration`
- POST `/auth/login` (API-only login) - Password grant 與 `/auth/login``/auth/refresh` aliases 已停用token exchange 統一使用 `/oauth/token`
- POST `/auth/refresh`
- POST `/newsletter/subscribe` - POST `/newsletter/subscribe`
- GET `/newsletter/confirm` - GET `/newsletter/confirm`
- POST `/newsletter/unsubscribe` - POST `/newsletter/unsubscribe`
@ -153,31 +294,64 @@
### Auth / Scope ### Auth / Scope
- `tenant_api` / `send_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界 - `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:list.read``newsletter:list.read.global`、`newsletter:subscriptions.read``newsletter:subscriptions.write``newsletter:send.write`、`newsletter:send.read``newsletter:events.read`
- 新增 scope`newsletter:events.write` - 新增 scope`newsletter:events.write`
- 新增 scope`newsletter:events.write.global` - 新增 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 驗簽 - JWT Access Token 已改為 JWS`DisableAccessTokenEncryption`),供 Send Engine 以 JWKS 驗簽
- `aud` 計算由 resource registry 驅動,不於 token 發放流程硬寫各服務 audience
- tenant-scoped API 採 fail-closedtoken 缺少、格式錯誤或不相符的 `tenant_id` 時拒絕;跨 tenant 操作必須使用獨立 global scope
### 租戶端取 TokenClient Credentials ### 租戶端取 TokenClient Credentials
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token - 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
- token 內含 `tenant_id` 與 scope - token 內含 `tenant_id` 與 scope
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWTJWS - Send Engine 收到租戶請求後以 JWKS 驗簽 JWTJWS
- 驗簽通過後將 `tenant_id` 固定在 request context不接受 body 覆寫 - 驗簽通過後將 `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 尚未完成(待辦) ## 7.2 尚未完成(待辦)
- `POST /webhooks/lists/full-sync`Member Center 端尚未發送此事件(僅保留契約) - `POST /webhooks/lists/full-sync`Member Center 端尚未發送此事件(僅保留契約)
- 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)尚未在註冊流程落地 - 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)已在註冊 / external login 流程落地
- `subscription.linked_to_user` 事件尚未發送 - `subscription.linked_to_user` 事件發送
- 安全設定頁access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime - 安全設定頁的 access/refresh 時效已套用到每次 OpenIddict token 簽發access 限 `51440` 分鐘refresh 限 `1365`
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足 - Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足
- resource registry 已完成 DB 驅動第一版file access delegated token issuing / validation 已由 Member Center API 實作
## 8. 安全與合規 ## 8. 安全與合規
- API / Web 的 Production database connection string 採 fail-closed禁止缺少設定時退回 localhost 預設帳密
- Member Center Web 對所有不安全 HTTP method 啟用全域 antiforgery 驗證Admin POST 表單明確送出 antiforgery token
- SMTP 密碼以版本化 Data Protection ciphertext 儲存;既有明碼在首次讀取時自動轉換,不回傳至管理 UI
- Production 必須再以外部憑證保護 Data Protection key ringkey ring 與密文同存 DB 只能避免設定值直接裸露,不能抵抗完整 DB 外洩
- Production API 必須以設定注入的 PFX 作為 OpenIddict signing / encryption certificatesAPI 與 Web 使用同一張外部 PFX 保護共用 Data Protection key ring
- Forwarded headers 採 trusted proxy allowlist未設定 proxy IP/CIDR 時完全忽略,避免外部來源偽造 client IP 或 HTTPS scheme
- HTTPS redirect 與 HSTS 由 AWS ALB / CloudFront 邊界負責API / Web 私有 listener 使用 HTTP不在應用程式層 redirect
- Production issuer 固定為 canonical HTTPS URL同 VPC S2S 可用 `Auth:AllowInternalHttpTokenEndpoint` 直連私有 HTTP OAuth endpoint兩者語意分離
- `tests/MemberCenter.Security.Tests` 是標準 xUnit test project供 CI 以 `dotnet test` 驗證 redirect、proxy、憑證與 security options 邊界
- Member Center Web 全域套用 self-only CSP、anti-framing、nosniff、no-referrer 與 Permissions-PolicyRazor 不保留 inline script/style
- Identity 密碼/lockout、API/Web rate limits、Newsletter token lifetime/batch limit 與 File Access delegated token lifetime 均由強型別 runtime options 管理,預設值記錄於 `docs/CONFIGURATION.md`
- 密碼強度與防暴力破解rate limit + lockout - 密碼強度與防暴力破解rate limit + lockout
- Token rotation + refresh token revoke - Token rotation + refresh token revoke
- Redirect URI 白名單 + PKCE - Redirect URI 白名單 + PKCE
- Double opt-in可配置 - Double opt-in可配置
- Audit log - Audit log
- delegated download token 需短效、不可重放,必要時可引入 `jti` 與 nonce/jti blacklist
- Email 可作為未來 MFA 的挑戰通道
- GDPR/CCPA資料匯出與刪除規劃中 - GDPR/CCPA資料匯出與刪除規劃中
## 9. 其他文件 ## 9. 其他文件

View File

@ -9,10 +9,22 @@
- [UI] 會員中心寄送驗證信 - [UI] 會員中心寄送驗證信
## F-02 登入OAuth2 + OIDC ## F-02 登入OAuth2 + OIDC
- [API] 站點送出 `POST /auth/login` 取得 access_token + id_token - [UI] 使用 `usage=web_login` OAuth client導向 `/oauth/authorize` 完成 Authorization Code + PKCE
- [API] 站點建立自身 session - [UI] 若未登入,會員中心會導向 Web login登入後回到原 authorize request
- [UI] 導向 `/oauth/authorize` 完成授權碼流程 - [UI] 站點用 code + code_verifier 換 token
- [UI] 站點用 code 換 token + id_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
- [Auth] 不支援 password grant 或未註冊 clientrefresh token 統一送至 `/oauth/token`
- [Auth] 新簽發 token 套用後台安全設定access token `51440` 分鐘、refresh token `1365`
## 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
- [Auth] Login 與 Logout 使用各自 allowlist並結構化比對 scheme、host、port 與 path segment禁止單純字串 prefix
- [UI] 登出完成後導回外站 callback
- [UI] 外站 callback 清除本站 session / token
## F-02b 內容站台呼叫 Send EngineClient Credentials + JWT 驗簽) ## F-02b 內容站台呼叫 Send EngineClient Credentials + JWT 驗簽)
- [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token`usage=send_api` - [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token`usage=send_api`
@ -20,19 +32,47 @@
- [API] Send Engine 以 Member Center JWKS 驗簽 token - [API] Send Engine 以 Member Center JWKS 驗簽 token
- [API] 驗證 `scope/tenant_id/exp` 通過後才受理任務 - [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 邊界一致後放行
- [API] issuing 與 validation 都採 tenant fail-closedBearer token 缺少或帶錯 `tenant_id` 時不得簽發或驗證成功
註記:下載流程不直接暴露一般 S2S token 給 client。
## F-03 忘記密碼 / 重設密碼 ## F-03 忘記密碼 / 重設密碼
- [API] 站點送出 `POST /auth/password/forgot` - [API] 站點送出 `POST /auth/password/forgot`
- [UI] 會員中心頁提交 email 並發送重設信 - [UI] 會員中心頁提交 email 並發送重設信
- [API/UI] 使用 token 進入重設密碼頁 - [API/UI] 使用 token 進入重設密碼頁
註記:目前 Web UI 已實作 forgot/reset 流程,但尚未串接 email 發送;開發階段會直接顯示 reset token 與 reset 連結。
## F-03b 已登入修改密碼
- [UI] 使用者登入後進入 `/account/changepassword`
- [UI] 輸入目前密碼與新密碼
- [UI] 會員中心驗證目前密碼後更新密碼
- [UI] 更新成功後刷新登入狀態
## F-04 訂閱電子報(未登入) ## F-04 訂閱電子報(未登入)
- [API] 站點送出 `POST /newsletter/subscribe` - [API] 站點以 `tenant_api` client credentials 取得含 `newsletter:subscriptions.write` 的 token
- [API] 站點帶 Bearer token 送出 `POST /newsletter/subscribe`token tenant 必須擁有指定清單
- [API] 會員中心建立 pending 訂閱並發送驗證信 - [API] 會員中心建立 pending 訂閱並發送驗證信
- [UI] 使用者點擊驗證信連結 `/newsletter/confirm?token=...` - [UI] 使用者點擊驗證信連結 `/newsletter/confirm?token=...`
- [UI] 訂閱改為 active發出 event `subscription.activated` - [UI] 訂閱改為 active發出 event `subscription.activated`
## F-05 取消訂閱(單一清單) ## F-05 取消訂閱(單一清單)
- [API] 站點以 `list_id + email` 呼叫 `POST /newsletter/unsubscribe-token` 取得 token - [API] 站點以 tenant Bearer token 與 `list_id + email` 呼叫 `POST /newsletter/unsubscribe-token` 取得 token;需 `newsletter:subscriptions.write`
- [UI] 使用者點擊退訂連結 `/newsletter/unsubscribe?token=...` - [UI] 使用者點擊退訂連結 `/newsletter/unsubscribe?token=...`
- [UI] 訂閱狀態改為 unsubscribed - [UI] 訂閱狀態改為 unsubscribed
- [API] 發出 event `subscription.unsubscribed` - [API] 發出 event `subscription.unsubscribed`
@ -43,11 +83,21 @@
- [API] Member Center 回傳 one-click `unsubscribe_token` - [API] Member Center 回傳 one-click `unsubscribe_token`
- [API] Send Engine 將 token 置入信件 `List-Unsubscribe` URL - [API] Send Engine 將 token 置入信件 `List-Unsubscribe` URL
## F-06 訂閱偏好管理(登入後) ## F-06 訂閱偏好管理(租戶站台 S2S
- [API] 站點以 `list_id + email` 讀取 `/newsletter/preferences` - [API] 站點以 tenant Bearer token 與 `list_id + email` 讀取 `/newsletter/preferences`;需 `newsletter:subscriptions.read`
- [API] 站點以 `list_id + email` 更新 `/newsletter/preferences` - [API] 站點以 tenant Bearer token 與 `list_id + email` 更新 `/newsletter/preferences`;需 `newsletter:subscriptions.write`
- [API] Member Center 驗證 token tenant 擁有指定清單,缺少或不符時拒絕
- [UI] 會員中心提供偏好頁(可選) - [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 ## F-10 Send Engine 事件同步Member Center → Send Engine
- [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`scope: `newsletter:events.write` - [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`scope: `newsletter:events.write`
- [API] Header 使用 `X-Signature` / `X-Timestamp` / `X-Nonce` / `X-Client-Id` - [API] Header 使用 `X-Signature` / `X-Timestamp` / `X-Nonce` / `X-Client-Id`
@ -56,6 +106,11 @@
- [API] Send Engine 驗證簽章 + timestamp + nonce重放防護後入庫 - [API] Send Engine 驗證簽章 + timestamp + nonce重放防護後入庫
- [API] Send Engine 更新名單快照 - [API] Send Engine 更新名單快照
## F-10b 訂閱名單讀取Send Engine → Member Center
- [API] tenant client 以 `newsletter:list.read` 呼叫 `GET /newsletter/subscriptions?list_id=...`
- [API] Member Center 驗證 token `tenant_id` 與清單 tenant 一致claim 缺少、格式錯誤或不符時拒絕
- [API] 平台級跨 tenant 同步必須使用獨立的 `newsletter:list.read.global`
## F-11 黑名單回寫Send Engine → Member Center ## F-11 黑名單回寫Send Engine → Member Center
- [API] Send Engine 依事件規則處理: - [API] Send Engine 依事件規則處理:
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:回寫後由 Member Center 取消該 email 的所有訂閱(跨租戶)並加入黑名單 - [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:回寫後由 Member Center 取消該 email 的所有訂閱(跨租戶)並加入黑名單
@ -73,13 +128,31 @@
## F-07 會員資料查看 ## F-07 會員資料查看
- [API] 站點讀取 `/user/profile` - [API] 站點讀取 `/user/profile`
- [API] `/user/profile``/user/addresses``/user/subscriptions` 僅能存取 access token subject 對應的會員資料
- [API] Email 不是公開識別資料,不提供 by-email 跨會員查詢端點
- [UI] 會員中心提供個人資料頁 - [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 ## F-08 管理者管理租戶/清單/Client
- [UI] 會員中心管理後台進行 CRUD - [UI] 會員中心管理後台進行 CRUD
## F-09 訂閱與會員綁定 ## F-09 訂閱與會員綁定
- [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定 - [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定
- [API] 發送事件 `subscription.linked_to_user` - [API] 發送事件 `subscription.linked_to_user`
註記:此流程目前尚未在程式中落地(屬待辦)。

View File

@ -2,6 +2,9 @@
此文件定義「首次安裝」與「既有環境管理」兩種情境,並以 EF Core Code First 為主。 此文件定義「首次安裝」與「既有環境管理」兩種情境,並以 EF Core Code First 為主。
完整環境變數、執行期 options、DB 管理設定與預設值請見 `docs/CONFIGURATION.md`
現有 OAuth scopes、resource 與 client usage mapping 請見 `docs/SCOPES.md`
## 目標 ## 目標
- 透過 EF Core migrations 建立/更新 DB schema - 透過 EF Core migrations 建立/更新 DB schema
- 建立或更新 superuser 帳號 - 建立或更新 superuser 帳號
@ -32,6 +35,7 @@
### 環境變數(建議用於部署) ### 環境變數(建議用於部署)
- `ConnectionStrings__Default`: 主要連線字串(優先) - `ConnectionStrings__Default`: 主要連線字串(優先)
- `MEMBERCENTER_CONNECTION`: 備用連線字串 - `MEMBERCENTER_CONNECTION`: 備用連線字串
- API / Web 在非 Development 環境必須提供 `ConnectionStrings__Default`缺少或空值時會拒絕啟動localhost 預設值僅供 Development。
若在開發環境(`ASPNETCORE_ENVIRONMENT=Development``DOTNET_ENVIRONMENT=Development` 若在開發環境(`ASPNETCORE_ENVIRONMENT=Development``DOTNET_ENVIRONMENT=Development`
可以建立 `.env` 檔案installer 與 API 會在啟動時讀取(僅限開發環境)。 可以建立 `.env` 檔案installer 與 API 會在啟動時讀取(僅限開發環境)。
@ -41,12 +45,91 @@
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
Auth__Issuer=http://localhost:7850/ Auth__Issuer=http://localhost:7850/
Auth__MemberCenterAudience=member_center_api Auth__WebLoginUrl=http://localhost:5080/account/login
Auth__SendEngineAudience=send_engine_api Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__AllowInternalHttpTokenEndpoint=false
# Optional certificate file overrides. Normally installer provisions app certificates into DB.
Auth__Certificates__Signing__Path=
Auth__Certificates__Signing__Password=
Auth__Certificates__Encryption__Path=
Auth__Certificates__Encryption__Password=
DataProtection__Certificate__Path=
DataProtection__Certificate__Password=
ReverseProxy__KnownProxies=
ReverseProxy__KnownNetworks=
ReverseProxy__ForwardLimit=1
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__BaseUrl=http://localhost:6060
SendEngine__WebhookSecret=change-me 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不會同時成為 login allowlist。
- Return URL allowlist 會結構化比對 `scheme + host + port + path segment prefix`,設定值不得含 userinfo、query 或 fragment例如 `https://example.com/app` 不會允許 `https://example.com.attacker.tld``/application`
- Identity cookie 固定使用 `SameSite=None``Secure=Always``Path=/`,因此 stage/prod 必須使用 HTTPS。
- AWS 架構由 ALB / CloudFront 終止 TLS並負責 HTTP→HTTPS redirect 與 HSTSMember Center 私有 listener 使用 HTTP不在應用程式層 redirect避免私有網域、health check 與 S2S 呼叫形成循環。
- 非 Development 的 `Auth__Issuer` 為必填且必須使用 canonical HTTPS URL缺少或使用 HTTP 時 API 拒絕啟動。
- 同 VPC 服務若需直接呼叫私有 HTTP `/oauth/token`,設定 `Auth__AllowInternalHttpTokenEndpoint=true`;此設定只放寬 OAuth endpoint transport不放寬 issuer 或外部 return URL。
- 私有 HTTP listener 必須以 Security Group 限制來源CMS 可使用內部 HTTP token endpoint但驗證 token 時仍必須接受 canonical HTTPS issuer。
- 非 Development 若 `Auth__Issuer` 不是 HTTPSAPI 會拒絕啟動Login / Logout 的外部 HTTP return URL 也會被拒絕。
- TLS 終止於 reverse proxy 時,必須讓應用程式接受 proxy 提供的 forwarded headers否則登入與 callback redirect 可能從 `https` 退回 `http`
Reverse proxy 信任設定:
- AWS ALB / managed reverse proxy 的 private IP 會變動時,建議由 Security Group 限制 Member Center 只接受該 proxy 連入,並設定:
- `ReverseProxy__TrustForwardedHeaders=true`
- `ReverseProxy__ForwardLimit=1`
- `ReverseProxy__TrustForwardedHeaders=true` 會接受 `X-Forwarded-For``X-Forwarded-Proto``X-Forwarded-Host`;只有在 app inbound 已由 Security Group / 私有網路限制為可信 proxy 時才可使用。
- 若不使用 `TrustForwardedHeaders`,未設定 `ReverseProxy__KnownProxies` / `ReverseProxy__KnownNetworks`API 與 Web 完全忽略 forwarded headers。
- `ReverseProxy__KnownProxies` 使用逗號分隔 IP例如 `10.0.0.10,10.0.0.11`
- `ReverseProxy__KnownNetworks` 使用逗號分隔 CIDR例如 `10.0.0.0/24,fd00::/64`
- `ReverseProxy__ForwardLimit` 預設為 `1`,只應設為實際 proxy hop 數,允許範圍 `15`
- 不使用 `TrustForwardedHeaders` 而改用 `KnownNetworks` 時,不可填入 `0.0.0.0/0``::/0`;正式環境只信任 load balancer / ingress 的固定 IP 或內部網段。
Web security headers
- Web 全域送出受限 Content Security Policy禁止 object、外部 frame 與 inline script/style。
- 同時送出 `X-Content-Type-Options: nosniff``X-Frame-Options: DENY``Referrer-Policy: no-referrer` 與受限 `Permissions-Policy`
- 新增外部資產或第三方前端服務時,必須先明確調整 CSP不可直接加入 `'unsafe-inline'` 或萬用來源。
- Google login 啟用時,`form-action` 會額外允許 `https://accounts.google.com`,因為 `/Account/ExternalLogin` 的 form submit 會被 ASP.NET Core external auth challenge 導向 Google OAuth endpoint。
SMTP 密碼儲存:
- `smtp_password``protected:v1:` 開頭的 Data Protection ciphertext 儲存,不需要新增 migration 或重建既有 DB。
- 舊版明碼會在第一次讀取 SMTP 設定時自動加密;開啟 `/admin/security`、寄信或送測試信都會觸發。
- `DataProtectionKeys` 是解密必要資料不可任意清空Production 應再以外部憑證保護 key ring並將憑證納入備份與輪替程序。
Production 憑證:
- Installer `init` / `migrate` 會在 DB `system_flags` 自動產生三張 app-managed PFXOpenIddict signing、OpenIddict encryption、Data Protection。
- API 非 Development 啟動時會優先使用 `Auth__Certificates__Signing__Path` / `Auth__Certificates__Encryption__Path`,未設定時改讀 DB。
- Web 非 Development 啟動時會優先使用 `DataProtection__Certificate__Path`,未設定時改讀 DB。
- API 與 Web 必須使用同一份 DB 與同一份 Data Protection certificate因此正式部署順序是先跑 installer migration再啟動 API/Web。
- 檔案型 PFX 設定只作為覆寫或手動輪替用途;若設定了 path檔案必須存在、含 private key 且在有效期內。
- signing、encryption 與 Data Protection 憑證分離;輪替時需保留仍用於驗證舊 token解密舊資料的前一代憑證完成相容輪替後才能移除。
- 前代檔案憑證以 `__Previous__0__Path/Password` 設定,可依序增加 `Previous__1__...`DB-managed 前代輪替管理介面尚未建立。
- 應監控憑證到期日並先在 Stage 驗證DB certificate 缺失、缺 private key 或已過期時應用程式會拒絕啟動。
- 此處監控的是 OpenIddict signing / encryption 與 Data Protection PFX對外 TLS/SSL 憑證若由 AWS ACM 管理,續期與告警由 ACM / AWS 邊界負責。
- `Certificates__ExpirationWarningDays` 預設 `30`(允許 `1180`);進入期限後 API / Web 啟動會寫入 warning logProduction 應以 CloudWatch metric filter / alarm 監控該 warning。
安全回歸檢查:
```bash
dotnet test tests/MemberCenter.Security.Tests/MemberCenter.Security.Tests.csproj
```
此標準 xUnit test project 目前涵蓋 return URL 邊界、HTTPS 限制、trusted proxy fail-closed / CIDR、憑證必要設定與 security options 預設/驗證。
`SendEngine` 設定說明: `SendEngine` 設定說明:
- `SendEngine__BaseUrl`: Send Engine API base URL - `SendEngine__BaseUrl`: Send Engine API base URL
- `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致 - `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致
@ -74,12 +157,12 @@ SendEngine__WebhookSecret=change-me
- 若 appsettings 中缺少連線字串,會互動式詢問並寫入 - 若 appsettings 中缺少連線字串,會互動式詢問並寫入
- 若設定環境變數,會優先使用環境變數(不寫入 appsettings - 若設定環境變數,會優先使用環境變數(不寫入 appsettings
2) 執行 migrations不 Drop 2) 執行 migrations不 Drop
3) 建立 rolesadmin, support 3) 建立 rolessuperuser, admin, support
4) 建立 admin不存在才建立並加入 admin 角色 4) 建立使用者(不存在才建立)並加入 `superuser` 角色
5) 寫入安裝鎖定DB flag: `system_flags` / `installed=true` 5) 寫入安裝鎖定DB flag: `system_flags` / `installed=true`
### 2) `installer add-admin` ### 2) `installer add-superuser`
用途:新增 superuser 用途:新增或提升 superuser
參數: 參數:
- `--admin-email <email>` - `--admin-email <email>`
@ -88,10 +171,13 @@ SendEngine__WebhookSecret=change-me
流程: 流程:
1) 解析連線字串 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>` - `--admin-email <email>`
@ -101,6 +187,9 @@ SendEngine__WebhookSecret=change-me
1) 解析連線字串 1) 解析連線字串
2) 更新密碼(強制) 2) 更新密碼(強制)
相容性:
- 舊指令 `installer reset-admin-password` 仍保留為 alias目前語意等同 `installer reset-superuser-password`
### 4) `installer migrate` ### 4) `installer migrate`
用途:只執行 migrations 用途:只執行 migrations

803
docs/MEMBER_UPGRADE_PLAN.md Normal file
View File

@ -0,0 +1,803 @@
# 會員中心升級規劃
此文件整理會員中心的目標升級架構包含設定畫面、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原有 by-email service API 已停用Email 不作為跨會員查詢鍵。
- 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
- Weblogin / register / forgot password / resend verification
- APIregister / forgot password / resend verification
- Newsletter APIpublic subscribe / unsubscribe token
- password grant 與 anonymous OAuth client 已移除,使用者登入統一走已註冊 `web_login` client 的 Authorization Code + PKCE
- 已完成 `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 成功後更新 `last_login_at` / `last_seen_at`
- disabled 帳號無法透過 Web login、external login 取得登入
- 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
- Login / Logout return URL 已拆分 allowlist 並改用結構化 URI 驗證,防止 host 與 path prefix 混淆
進行中:
- 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 tokenFile 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 細節仍待整理。
- one-click unsubscribe token 已有可設定的 rate limit預設每 client/IP 每分鐘 60 次);更細的風控觀測仍待補。
## 功能規劃
### 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 密碼已改為版本化 Data Protection ciphertext既有明碼會在首次讀取時自動轉換Production key ring 憑證保護仍待補
- token lifetime 已套用至 OpenIddict 簽發流程,設定更新後的新 token 立即使用新時效
- 已完成 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 規範
狀態:`current-user 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 API 已完成第一版,且一律從 access token subject 解析目前會員
- by-email service API 已停用Email 不可用來查詢或修改其他會員資料
- audience / resource registry DB 驅動第一版已完成
#### 6.3 API 邊界建議
- 其他服務 API
- 不得沿用 current-user profile scopes 查詢其他會員
- 不得以 Email 作為跨會員 lookup key
- 公開社群資料應使用獨立 endpoint、公開 DTO 與 scope
- 未來有限個資整合應使用 S2S 專用 scope、穩定 user id、欄位投影與 audit log
- 預設以最小權限授權,不因 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
- 訂閱名單讀取已拆分 tenant scope `newsletter:list.read` 與平台 scope `newsletter:list.read.global`tenant claim 採 fail-closed
- 建立訂閱與申請退訂 token 已要求 tenant S2S scope `newsletter:subscriptions.write`;確認與實際退訂仍以一次性 token 匿名完成
- Email 型訂閱偏好 API 已要求 tenant S2S讀取使用 `newsletter:subscriptions.read`,更新使用 `newsletter:subscriptions.write`
- `TokenController` 已以 registry 解析 audiences
- OAuth client usage-scope matrix 已以 `auth_client_usage_permissions` 驅動
- File Access delegated token issuing / validation API 已落在 Member CenterFile Access agent 尚需串接 validation endpoint
### 9. 審計紀錄
狀態:`大致完成,少數治理事件待續作`
- 下列事件必須寫入 audit log
- 註冊
- Email 驗證成功
- 忘記密碼申請
- 密碼重設成功
- 已登入修改密碼
- Google external login 綁定
- 角色變更
- 帳號停用 / 啟用
- profile 修改
- 地址簿新增 / 編輯 / 刪除 / 預設地址切換
- 會員端直接退訂
- 系統設定修改
- OAuth client 建立
- OAuth client secret 旋轉
- OAuth client secret 顯示一次、旋轉與治理能力視為既有基線;細節之後再檢討。
目前進度:
- 帳號寄信、驗證、重設密碼、修改密碼、註冊、external login 綁定、角色變更、帳號停用 / 啟用、profile、地址、會員端退訂、系統設定修改均已有實作
- Member Center Web 已對不安全 HTTP method 啟用全域 antiforgery 驗證Admin POST 表單均明確帶 token
- API / Web forwarded headers 已改為 trusted proxy allowlist未設定 IP/CIDR 時 fail-closed
- HTTPS redirect / HSTS 已明確交由 AWS ALB / CloudFrontAPI / Web 不再對私有 HTTP listener 做應用程式層 redirect
- Production OpenIddict signing / encryption 與 Data Protection key-protection PFX 載入已完成;缺少、過期或無 private key 時拒絕啟動
- PFX 到期預警已加入啟動 log預設提前 30 天CloudWatch alarm 由部署環境設定
- API / Web 在非 Development 缺少 `ConnectionStrings:Default` 時會拒絕啟動localhost fallback 僅保留給 Development
- Production `Auth:Issuer` 已改為必填 HTTPS私有 HTTP OAuth 呼叫改由獨立的 `Auth:AllowInternalHttpTokenEndpoint` 控制
- Web security headers 已完成self-only CSP、frame deny、nosniff、no-referrer 與 Permissions-Policy
- 已加入標準 xUnit security tests涵蓋 return URL、HTTPS、trusted proxy、Production 憑證與 security options
- 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` 申請已補每 client/IP rate limit
- `/oauth/token` 已補可設定 rate limit預設每來源 IP 每分鐘 30 次Production 分散式限制由 AWS WAF 負責
- API / Web password reset 與 Email verify 已補可設定 rate limit預設每來源 IP 每 10 分鐘 10 次)
- 人類登入 flow 已有 lockoutservice 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 serviceFile 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`

View File

@ -81,7 +81,7 @@ curl -s -X POST https://{send-engine}/api/send-jobs \
- Send Engine 以 Member Center 的 JWKS 驗簽 access tokenJWS - Send Engine 以 Member Center 的 JWKS 驗簽 access tokenJWS
- 驗證重點:`iss``aud``scope``tenant_id``exp` - 驗證重點:`iss``aud``scope``tenant_id``exp`
- `iss`:由 Member Center `Auth__Issuer` 設定(例:`http://localhost:7850/` - `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 來源
--- ---

View File

@ -3,13 +3,15 @@
已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。 已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。
其中 `/webhooks/*` 為 Member Center 對外發送時遵循的整合契約(實際由 Send Engine 提供端點)。 其中 `/webhooks/*` 為 Member Center 對外發送時遵循的整合契約(實際由 Send Engine 提供端點)。
現有 OAuth scope catalog、resource、client usage mapping 與 endpoint 對照請見 `docs/SCOPES.md`
## 版本 ## 版本
- OpenAPI: 3.1.0 - OpenAPI: 3.1.0
- 檔案:`docs/openapi.yaml` - 檔案:`docs/openapi.yaml`
## 核心資源 ## 核心資源
- OAuth2/OIDC授權、token、discovery、JWKS - OAuth2/OIDC授權、token、discovery、JWKS
- Auth註冊、登入password grant、刷新、登出、忘記/重設密碼、Email 驗證 - Auth註冊、Authorization Code + PKCE 登入、刷新、登出、忘記/重設密碼、Email 驗證
- User個人資料 - User個人資料
- Newsletter訂閱/確認/退訂/偏好 - Newsletter訂閱/確認/退訂/偏好
- AdminTenants/Lists/OAuth ClientsMVP CRUD - AdminTenants/Lists/OAuth ClientsMVP CRUD
@ -19,14 +21,21 @@
- Bearer JWTAPI 使用) - Bearer JWTAPI 使用)
## 補充說明 ## 補充說明
- `/oauth/token``/auth/login``/auth/refresh` 使用 `application/x-www-form-urlencoded` - `/oauth/token` 使用 `application/x-www-form-urlencoded`,僅接受 authorization code、refresh token、client credentials
- `/oauth/token` 預設每來源 IP 每分鐘 30 次,透過 `RateLimits:Api:OAuthToken` 調整;超過回傳 `429`Production 仍需 AWS WAF
- Password grant 與 anonymous OAuth client 已停用;使用者登入必須走已註冊 `web_login` client 的 Authorization Code + PKCE
- Authorization Code 與 Client Credentials token 的實際 granted scopes 都由 application `usage` 對應的 DB mapping 固定核發,不採用呼叫端自行指定的 scope
- Usage scope seed 會同步啟用與停用狀態;從 catalog 移除的 scope 不會繼續留在既有 Stage/Production mapping
- Token endpoint 會套用後台安全設定access token `51440` 分鐘、refresh token `1365` 天;設定儲存後下一次簽發立即生效
- Access token 以 JWTJWS簽發建議驗證 `iss``aud` - Access token 以 JWTJWS簽發建議驗證 `iss``aud`
- `/auth/email/verify` 需要 `token` + `email` - `/auth/email/verify` 需要 `token` + `email`
- `/newsletter/subscribe` 會回傳 `confirm_token` - Password reset 與 Email verify 預設每來源 IP 每 10 分鐘 10 次,透過 Web/API `AuthTokenConsumption` rate-limit options 調整
- `/newsletter/unsubscribe-token` 需要 `list_id + email` 才能申請 `unsubscribe_token` - `/newsletter/subscribe` 需 tenant S2S token成功後會回傳 `confirm_token`
- `/newsletter/unsubscribe-token` 需 tenant S2S token 與 `list_id + email` 才能申請 `unsubscribe_token`
- `/newsletter/one-click-unsubscribe-token` 提供 Send Engine 發信前取得 one-click 退訂 token`tenant_id + list_id + subscriber_id` - `/newsletter/one-click-unsubscribe-token` 提供 Send Engine 發信前取得 one-click 退訂 token`tenant_id + list_id + subscriber_id`
- `/newsletter/one-click-unsubscribe-tokens` 提供 Send Engine 批次取得 one-click 退訂 token`tenant_id + list_id + subscriber_ids[]` - `/newsletter/one-click-unsubscribe-tokens` 提供 Send Engine 批次取得 one-click 退訂 token`tenant_id + list_id + subscriber_ids[]`
- `/newsletter/preferences`GET/POST需要 `list_id + email`,避免跨租戶資料讀取/更新 - One-click token 單筆/批次端點預設每 client/IP 每分鐘 60 次,透過 `RateLimits:Api:NewsletterOneClickToken` 調整
- `/newsletter/preferences`GET/POST需要 tenant S2S token 與 `list_id + email`,避免跨租戶資料讀取/更新
## 通用欄位 ## 通用欄位
- `occurred_at`RFC3339`2026-02-10T09:30:00Z` - `occurred_at`RFC3339`2026-02-10T09:30:00Z`
@ -68,10 +77,20 @@
## OAuth Client 用途分離(強制) ## OAuth Client 用途分離(強制)
- `usage=tenant_api` - `usage=tenant_api`
- 供租戶站台拿 token 呼叫 Member Center / Send Engine API - 供租戶站台拿 token 呼叫 Member Center / Send Engine API
- scope 僅給業務所需(如 `newsletter:events.write` - scope 僅給業務所需(如 `newsletter:subscriptions.read`、`newsletter:subscriptions.write``newsletter:events.write`
- `usage=send_api` - `usage=send_api`
- 供租戶站台呼叫 Send Engine 發信流程 - 供租戶站台呼叫 Send Engine 發信流程
- 內建 scope`newsletter:send.write``newsletter:send.read` - 內建 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`、全部 current-user `profile:*`
- `usage=webhook_outbound` - `usage=webhook_outbound`
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途 - 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
- 不可用於租戶 API 呼叫 - 不可用於租戶 API 呼叫
@ -80,8 +99,8 @@
- `usage=platform_service` - `usage=platform_service`
- 供平台級 S2S例如 SES 聚合事件回寫) - 供平台級 S2S例如 SES 聚合事件回寫)
- 可不綁定 `tenant_id`scope 使用 `newsletter:events.write.global` - 可不綁定 `tenant_id`scope 使用 `newsletter:events.write.global`
- `tenant_api` / `send_api` / `platform_service` 建議(且實作要求)`client_type=confidential` - `tenant_api` / `send_api` / `platform_service` / `file_api` 建議(且實作要求)`client_type=confidential`
- `redirect_uris``webhook_outbound` 需要;其他 usage 可為空 - `redirect_uris``web_login` / `webhook_outbound` 需要;其他 usage 可為空
- 管理規則: - 管理規則:
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound` - 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`
- 平台級流程另建 `platform_service` 憑證 - 平台級流程另建 `platform_service` 憑證
@ -98,25 +117,94 @@
- `POST /newsletter/one-click-unsubscribe-tokens`已實作Send Engine 批次申請 one-click token - `POST /newsletter/one-click-unsubscribe-tokens`已實作Send Engine 批次申請 one-click token
### Auth / Scope ### Auth / Scope
- `tenant_api` / `send_api` / `webhook_outbound` 類型需綁定 `tenant_id` - `tenant_api` / `send_api` / `file_api` / `webhook_outbound` 類型需綁定 `tenant_id`
- `platform_service` 可不綁定 `tenant_id` - `platform_service` 可不綁定 `tenant_id`
- 新增 scope - 新增 scope
- `newsletter:list.read` - `newsletter:list.read`
- `newsletter:list.read.global`
- `newsletter:subscriptions.read`
- `newsletter:subscriptions.write`
- `newsletter:send.write` - `newsletter:send.write`
- `newsletter:send.read` - `newsletter:send.read`
- `newsletter:events.read` - `newsletter:events.read`
- `newsletter:events.write` - `newsletter:events.write`
- `newsletter:events.write.global` - `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 控制
- 現有 `/user/profile``/user/addresses``/user/subscriptions` 為 current-user API資料主體固定為 access token `sub`
- `profile:*` scope 不授權查詢或修改其他會員by-email API 已停用
- 未來公開會員資料與有限個資整合必須使用獨立 endpoint、DTO 與 scope
- 公開會員 API 不在目前範圍;待外部網站有明確欄位與用途後,依最小揭露原則逐功能新增
- 規劃新增 file access scopes
- `files:upload.write`
- `files:download.read`
- `files:download.delegate`
- `files:delete`
- `files:metadata.read`
- 規劃新增 audience
- `file_access_api`
- 發送引擎僅能用上述 scope禁止 admin 權限 - 發送引擎僅能用上述 scope禁止 admin 權限
- `POST /subscriptions/disable` 需 Bearer token 且包含下列其一: - `POST /subscriptions/disable` 需 Bearer token 且包含下列其一:
- `newsletter:events.write`tenant-scoped - `newsletter:events.write`tenant-scoped
- `newsletter:events.write.global`platform-scopedSES 回寫用) - `newsletter:events.write.global`platform-scopedSES 回寫用)
- `GET /newsletter/subscriptions` 需包含下列其一:
- `newsletter:list.read`token 必須含有效 `tenant_id`,且只能讀取該 tenant 的清單
- `newsletter:list.read.global`:允許平台服務跨 tenant 讀取
- tenant-scoped API 採 fail-closed缺少、格式錯誤或不相符的 `tenant_id` 一律拒絕
- `POST /newsletter/subscribe``POST /newsletter/unsubscribe-token``newsletter:subscriptions.write`token tenant 必須擁有指定 `list_id`
- `GET /newsletter/preferences``newsletter:subscriptions.read``POST /newsletter/preferences``newsletter:subscriptions.write`;兩者都驗證清單 tenant
- `/newsletter/confirm``/newsletter/unsubscribe` 是收件者的一次性 token 完成端點,維持匿名存取
- 建議 Send Engine 使用 client credentials 取 token不建議使用長效固定 token - 建議 Send Engine 使用 client credentials 取 token不建議使用長效固定 token
- Send Engine 建議以 JWKS 驗簽 JWTJWS並驗證 `scope/tenant_id/exp` - Send Engine 建議以 JWKS 驗簽 JWTJWS並驗證 `scope/tenant_id/exp`
- `iss``Auth:Issuer` 設定(例:`http://localhost:7850/` - `iss``Auth:Issuer` 設定(例:`http://localhost:7850/`
- `aud` 預設: - `aud` 由 Auth resource registry 決定,而非每個服務各自新增一組獨立設定
- Send Engine 流程:`send_engine_api`(可用 `Auth:SendEngineAudience` 覆寫) - 標準資源包含:
- Member Center API 流程:`member_center_api`(可用 `Auth:MemberCenterAudience` 覆寫) - Send Engine 流程:`send_engine_api`
- Member Center API 流程:`member_center_api`
- File access 流程:`file_access_api`
- `TokenController` 依 scope / usage 對照 resource registry 計算 `aud`
### File Access AuthA 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`;缺少、格式錯誤或與 request 不符時拒絕
- 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 與 validationFile Access agent 負責把實際 GET request 的 tenant/file/method 邊界帶回 Member Center 驗證
### 回寫原因碼Send Engine -> Member Center ### 回寫原因碼Send Engine -> Member Center
- `hard_bounce` - `hard_bounce`

113
docs/SCOPES.md Normal file
View File

@ -0,0 +1,113 @@
# OAuth Scope 現況
本文只記錄目前程式已註冊與 seed 的 scope。Client Credentials 與 Authorization Code 實際核發的 scope 由 OAuth application `usage` 對應的 DB mapping 決定,不接受呼叫端自行擴權。修改預設 mapping 後,既有環境需執行 `sync-oauth-clients`
## 授權邊界
- `profile:*` 是 current-user scope資料主體固定為 access token 的 `sub`,不能查詢其他會員。
- Email 不是跨會員 lookup key目前沒有 by-email service API。
- Tenant scope 必須帶有效 `tenant_id`,且與 request/list tenant 相符;缺少或不符時 fail-closed。
- `.global` scope 不依賴 tenant claim只應授予平台級 confidential client。
- Superuser/Admin 後台權限不使用 OAuth scopes而使用 Web role + Admin Permission。
## Resource / Audience
| Resource | 預設 audience | Tenant | Scopes |
|---|---|---|---|
| `member_center_api` | `member_center_api` | scope 個別判定 | OIDC、`profile:*``newsletter:list.*``newsletter:subscriptions.*``newsletter:events.*` |
| `send_engine_api` | `send_engine_api` | 必須 | `newsletter:send.read``newsletter:send.write` |
| `file_access_api` | `file_access_api` | 必須 | `files:*` |
Token audience 由 scope → resource registry 計算。
## Scope Catalog
### OIDC
| Scope | 能力 |
|---|---|
| `openid` | OpenID Connect subject / ID token。 |
| `email` | Email claim。 |
| `profile` | 標準 OIDC profile scope實際會員 API 仍需細分的 `profile:*`。 |
### Current-user Profile
| Scope | 能力 |
|---|---|
| `profile:basic.read` | 讀取目前登入會員基本資料。 |
| `profile:basic.write` | 修改目前登入會員基本資料。 |
| `profile:addresses.read` | 讀取目前會員地址簿。 |
| `profile:addresses.write` | 新增、修改、刪除目前會員地址。 |
| `profile:subscriptions.read` | 讀取目前會員的電子報訂閱。 |
| `profile:subscriptions.write` | 取消目前會員自己的訂閱。 |
### Newsletter / Member Center
| Scope | Tenant 規則 | 能力 |
|---|---|---|
| `newsletter:list.read` | token tenant 必須擁有 list | 依 list 讀取訂閱名單。 |
| `newsletter:list.read.global` | 平台級 | 跨 tenant 讀取訂閱名單。 |
| `newsletter:subscriptions.read` | token tenant 必須擁有 list | 依 list + email 讀取訂閱偏好。 |
| `newsletter:subscriptions.write` | token tenant 必須擁有 list | 建立訂閱、簽發一般退訂 token、更新偏好。 |
| `newsletter:events.read` | 依 resource service 規則 | 讀取電子報事件;目前 Member Center 無直接 endpoint。 |
| `newsletter:events.write` | `tenant_id` 必須匹配 | Tenant 事件回寫、one-click token、disable subscription。 |
| `newsletter:events.write.global` | 平台級 | 跨 tenant 事件回寫、one-click token、disable subscription、webhook client mapping。 |
### Send Engine
| Scope | 能力 |
|---|---|
| `newsletter:send.write` | 在 Send Engine 建立/執行發送工作。 |
| `newsletter:send.read` | 在 Send Engine 讀取發送狀態。 |
上述 scope 的資源是 Send Engine不是 Member Center controller。
### File Access
| Scope | 能力 |
|---|---|
| `files:upload.write` | File Access service 上傳檔案。 |
| `files:download.read` | Access agent 呼叫 Member Center 驗證 delegated download token。 |
| `files:download.delegate` | 業務服務向 Member Center 簽發短效 download token。 |
| `files:metadata.read` | File Access service 讀取檔案 metadata。 |
| `files:delete` | File Access service 刪除檔案。 |
`files:upload.write``files:metadata.read``files:delete` 由 File Access service 驗證Member Center 目前只提供 delegated token issuing / validation。
## Client Usage 預設 Mapping
| Usage | Client 型態 / Tenant | 預設 scopes |
|---|---|---|
| `web_login` | public 或 confidential互動式登入 | `openid``email``profile`、全部 current-user `profile:*` |
| `tenant_api` | confidential必須綁 tenant | `newsletter:events.write``newsletter:list.read``newsletter:subscriptions.read/write`、全部 `profile:*` |
| `platform_service` | confidential可不綁 tenant | `newsletter:events.write.global``newsletter:list.read.global`、全部 `profile:*` |
| `send_api` | confidential必須綁 tenant | `newsletter:send.read/write` |
| `file_api` | confidential必須綁 tenant | 全部 `files:*` |
| `webhook_outbound` | 現有 webhook credential usage | `openid``email``profile``newsletter:events.write` |
Mapping seed 採完整集合同步:從預設 mapping 移除的 scope 會在 DB mapping 停用。個別 OAuth application permissions 由 Installer `sync-oauth-clients` 更新。
現有 `tenant_api` / `platform_service` mapping 雖包含 `profile:*`,但 current-user endpoint 仍要求 `sub` 對應實際會員Client Credentials principal 不能用這些 endpoint 查詢其他會員。目前沒有 S2S 會員資料 endpoint。
## Member Center Endpoint 對照
| Endpoint | Scope |
|---|---|
| `GET /user/profile` | `profile:basic.read` |
| `POST /user/profile` | `profile:basic.write` |
| `GET /user/addresses` | `profile:addresses.read` |
| `POST /user/addresses``DELETE /user/addresses/{id}` | `profile:addresses.write` |
| `GET /user/subscriptions` | `profile:subscriptions.read` |
| `POST /user/subscriptions/{id}/unsubscribe` | `profile:subscriptions.write` |
| `POST /newsletter/subscribe` | `newsletter:subscriptions.write` |
| `POST /newsletter/unsubscribe-token` | `newsletter:subscriptions.write` |
| `GET /newsletter/preferences` | `newsletter:subscriptions.read` |
| `POST /newsletter/preferences` | `newsletter:subscriptions.write` |
| `GET /newsletter/subscriptions` | `newsletter:list.read``.global` |
| `POST /newsletter/one-click-unsubscribe-token(s)` | `newsletter:events.write``.global` |
| `POST /subscriptions/disable` | `newsletter:events.write``.global` |
| `POST /integrations/send-engine/webhook-clients/upsert` | `newsletter:events.write.global` |
| `POST /file-access/download-tokens` | `files:download.delegate` |
| `POST /file-access/download-tokens/validate` | `files:download.read` |
以下端點依一次性 token 操作,不要求 OAuth scope`GET /newsletter/confirm``POST /newsletter/unsubscribe`。公開 auth endpointregister、forgot/reset、email verify亦不屬於 scope 授權模型。

71
docs/TEST_SITE.md Normal file
View File

@ -0,0 +1,71 @@
# 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": ""
}
}
```
不要將 OAuth client secret 寫入或提交到 `appsettings.json`。建議使用環境變數
`MemberCenter__ServiceClientSecret`,或僅在測試表單暫時輸入;設定於 server-side 的 secret 不會回填至 HTML。
Repo 曾包含的 TestSite service client secret 必須在對應環境撤銷rotate只從 Git 移除不會使舊 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 不同 originMember 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. `GET /user/profile`
4. `POST /user/profile`
5. `GET /user/addresses`
6. `POST /user/addresses`
7. `GET /user/subscriptions`
8. `POST /user/subscriptions/{id}/unsubscribe`
測試站只做 happy path不取代完整自動化測試。

View File

@ -2,10 +2,11 @@
## 會員中心(統一 UI ## 會員中心(統一 UI
### 會員端 ### 會員端
- 註冊 / 登入 / 忘記密碼 - 註冊 / 登入 / 忘記密碼 / 修改密碼
- Email 驗證 - Email 驗證
- 個人資料Email 為主) - 個人資料(基本資料、聯絡方式、公司資訊)
- 訂閱管理(清單與偏好) - 收貨地址簿
- 訂閱管理(清單與退訂)
- 退訂(單一清單) - 退訂(單一清單)
- 連結外站(可選:回到來源站點) - 連結外站(可選:回到來源站點)
@ -16,13 +17,14 @@
- 訂閱查詢 / 匯出 - 訂閱查詢 / 匯出
- 審計紀錄查詢 - 審計紀錄查詢
- 系統設定安全策略、token 時效) - 系統設定安全策略、token 時效)
- Auth 資源設定resource / audience / scope / usage mapping
## 各站自建 UIAPI ## 各站自建 UIAPI
### 會員端 ### 會員端
- 登入 / 註冊 / 忘記密碼 - 登入 / 註冊 / 忘記密碼 / 修改密碼
- Email 驗證頁(可自建或導回會員中心) - Email 驗證頁(可自建或導回會員中心)
- 訂閱表單(未登入) - 訂閱表單(未登入)
- 訂閱偏好管理(登入後) - 外站自建訂閱偏好管理(登入後,走 API
- 退訂頁(從 email token 進來) - 退訂頁(從 email token 進來)
### 管理者端 ### 管理者端
@ -33,26 +35,37 @@
- 會員中心 UI 為統一入口(少數情境) - 會員中心 UI 為統一入口(少數情境)
- 其餘皆走 API 與各站自建 UI - 其餘皆走 API 與各站自建 UI
- 會員中心 UI 不承擔行銷內容或寄送 - 會員中心 UI 不承擔行銷內容或寄送
- `MemberCenter.Web` 採 client-first 介面admin 功能以角色判斷後顯示於同一登入入口內
- `/admin/*``Areas/Admin` 提供獨立管理區殼層
- 非 admin 或未登入存取 `/admin/*` 時,回 `404`
## UI 路徑對應Use Cases ## UI 路徑對應Use Cases
### 會員端(統一 UI ### 會員端(統一 UI
- UC-01 註冊會員: `/account/register` - UC-01 註冊會員: `/account/register`
- UC-02 登入: `/account/login` - 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 忘記密碼 / 重設密碼: `/account/forgotpassword`, `/account/resetpassword`
- UC-04.1 已登入修改密碼: `/account/changepassword`
- UC-05 Email 驗證: `/account/verifyemail?email=...&token=...` - UC-05 Email 驗證: `/account/verifyemail?email=...&token=...`
- UC-07 訂閱確認double opt-in: `/newsletter/confirm?token=...` - UC-07 訂閱確認double opt-in: `/newsletter/confirm?token=...`
- UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...` - UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...`
- UC-09 訂閱偏好管理(登入後): `/newsletter/preferences?list_id=...&email=...` - UC-09 訂閱偏好管理(外站整合 API: `/newsletter/preferences?list_id=...&email=...`
- UC-10 會員資料查看: `/profile` - UC-10 會員資料查看 / 編輯: `/profile`
- UC-10.1 收貨地址簿管理: `/profile/addresses`
- UC-10.2 我的電子報訂閱: `/profile/subscriptions`
### 管理者端(統一 UI ### 管理者端(統一 UI
- UC-11 租戶管理: `/admin/tenants` - UC-11 租戶管理: `/admin/tenants`
- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`UUID - 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 - 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``webhook_outbound` 需要;其餘 usage 不需要 - `redirect_uris``web_login` / `webhook_outbound` 需要;其餘 usage 不需要
- `tenant_api` / `send_api` / `platform_service` 強制 `client_type=confidential` - `tenant_api` / `send_api` / `platform_service` / `file_api` 強制 `client_type=confidential`
- 既有 public client 改為 confidential 時會自動產生新的 client_secret並只顯示一次之後需使用 rotate secret 重新產生
- UC-13 電子報清單管理: `/admin/newsletter-lists` - UC-13 電子報清單管理: `/admin/newsletter-lists`
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export` - UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
- UC-15 審計紀錄查詢: `/admin/audit-logs` - UC-15 審計紀錄查詢: `/admin/audit-logs`
- UC-16 安全策略設定: `/admin/security` - 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 整合設定頁

View File

@ -9,11 +9,12 @@
- UC-02 登入(取得 token [API/UI] - UC-02 登入(取得 token [API/UI]
- UC-03 登出 [API/UI] - UC-03 登出 [API/UI]
- UC-04 忘記密碼 / 重設密碼 [API/UI] - UC-04 忘記密碼 / 重設密碼 [API/UI]
- UC-04.1 已登入修改密碼 [UI]
- UC-05 Email 驗證 [API/UI] - UC-05 Email 驗證 [API/UI]
- UC-06 訂閱電子報(未登入) [API] - UC-06 訂閱電子報(未登入) [API]
- UC-07 訂閱確認double opt-in [UI] - UC-07 訂閱確認double opt-in [UI]
- UC-08 取消訂閱(單一清單) [UI] - UC-08 取消訂閱(單一清單) [UI]
- UC-09 訂閱偏好管理(登入後) [API/UI] - UC-09 訂閱偏好管理(登入後) [API]
- UC-10 會員資料查看Email 為主) [API/UI] - UC-10 會員資料查看Email 為主) [API/UI]
## 管理者端 ## 管理者端
@ -31,4 +32,4 @@
## 實作狀態2026-02 ## 實作狀態2026-02
- 已完成UC-17、UC-18以 webhook 事件發送) - 已完成UC-17、UC-18以 webhook 事件發送)
- 完成UC-19註冊後自動綁定 `user_id``subscription.linked_to_user` 事件) - 完成UC-19註冊後自動綁定 `user_id``subscription.linked_to_user` 事件)

View File

@ -67,6 +67,8 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/TokenResponse' $ref: '#/components/schemas/TokenResponse'
'429':
description: Too many token requests from this source IP
/.well-known/openid-configuration: /.well-known/openid-configuration:
get: get:
@ -100,45 +102,9 @@ paths:
schema: schema:
$ref: '#/components/schemas/UserProfile' $ref: '#/components/schemas/UserProfile'
/auth/login:
post:
summary: API login
security: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/PasswordTokenRequest'
responses:
'200':
description: Token response
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
/auth/refresh:
post:
summary: Refresh token
security: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/RefreshTokenRequest'
responses:
'200':
description: Token response
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
/auth/logout: /auth/logout:
post: post:
summary: Logout (revoke refresh token) summary: Logout current authenticated session
security: security:
- BearerAuth: [] - BearerAuth: []
requestBody: requestBody:
@ -181,6 +147,8 @@ paths:
responses: responses:
'204': '204':
description: Password reset description: Password reset
'429':
description: Too many password reset token attempts from this source IP
/auth/email/verify: /auth/email/verify:
get: get:
@ -198,10 +166,13 @@ paths:
responses: responses:
'200': '200':
description: Email verified description: Email verified
'429':
description: Too many email verification token attempts from this source IP
/user/profile: /user/profile:
get: get:
summary: Get current user profile summary: Get current user profile
description: Returns only the profile owned by the access token subject. Email cannot be used to look up another member.
security: security:
- BearerAuth: [] - BearerAuth: []
responses: responses:
@ -214,8 +185,9 @@ paths:
/newsletter/subscribe: /newsletter/subscribe:
post: post:
summary: Subscribe (unauthenticated allowed) summary: Create a pending subscription
security: [] security: [{ BearerAuth: [] }]
description: Requires tenant-scoped `newsletter:subscriptions.write`; the token tenant must own `list_id`.
requestBody: requestBody:
required: true required: true
content: content:
@ -307,7 +279,8 @@ paths:
/newsletter/unsubscribe-token: /newsletter/unsubscribe-token:
post: post:
summary: Issue unsubscribe token summary: Issue unsubscribe token
security: [] security: [{ BearerAuth: [] }]
description: Requires tenant-scoped `newsletter:subscriptions.write`; the token tenant must own `list_id`.
requestBody: requestBody:
required: true required: true
content: content:
@ -370,6 +343,8 @@ paths:
description: Unauthorized description: Unauthorized
'403': '403':
description: Forbidden description: Forbidden
'429':
description: Too many token requests for this client or IP
'404': '404':
description: Not found description: Not found
content: content:
@ -410,10 +385,14 @@ paths:
description: Unauthorized description: Unauthorized
'403': '403':
description: Forbidden description: Forbidden
'429':
description: Too many token requests for this client or IP
/newsletter/preferences: /newsletter/preferences:
get: get:
summary: Get preferences summary: Get preferences
security: [{ BearerAuth: [] }]
description: Requires tenant-scoped `newsletter:subscriptions.read`; the token tenant must own `list_id`.
parameters: parameters:
- in: query - in: query
name: list_id name: list_id
@ -436,6 +415,10 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
'403':
description: Forbidden
'404': '404':
description: Not found description: Not found
content: content:
@ -445,6 +428,8 @@ paths:
post: post:
summary: Update preferences summary: Update preferences
security: [{ BearerAuth: [] }]
description: Requires tenant-scoped `newsletter:subscriptions.write`; the token tenant must own `list_id`.
requestBody: requestBody:
required: true required: true
content: content:
@ -469,6 +454,10 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
'403':
description: Forbidden
'404': '404':
description: Not found description: Not found
content: content:
@ -480,7 +469,9 @@ paths:
get: get:
summary: List subscriptions by list summary: List subscriptions by list
security: [{ BearerAuth: [] }] security: [{ BearerAuth: [] }]
description: Requires scope `newsletter:list.read`. description: |
Requires `newsletter:list.read` with a `tenant_id` claim matching the requested list,
or `newsletter:list.read.global` for platform-wide access. Missing tenant claims are denied.
parameters: parameters:
- in: query - in: query
name: list_id name: list_id
@ -810,6 +801,48 @@ paths:
'204': '204':
description: Deleted 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: components:
securitySchemes: securitySchemes:
OAuth2: OAuth2:
@ -823,20 +856,36 @@ components:
email: Email email: Email
profile: Basic profile profile: Basic profile
newsletter:list.read: Read newsletter subscriptions by list newsletter:list.read: Read newsletter subscriptions by list
newsletter:list.read.global: Read newsletter subscriptions across tenants
newsletter:subscriptions.read: Read tenant subscription preferences
newsletter:subscriptions.write: Create subscriptions and issue unsubscribe tokens
newsletter:send.write: Create/send newsletter jobs newsletter:send.write: Create/send newsletter jobs
newsletter:send.read: Read newsletter send status newsletter:send.read: Read newsletter send status
newsletter:events.read: Read newsletter events newsletter:events.read: Read newsletter events
newsletter:events.write: Write newsletter events (tenant scoped) newsletter:events.write: Write newsletter events (tenant scoped)
newsletter:events.write.global: Write newsletter events (platform 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: clientCredentials:
tokenUrl: /oauth/token tokenUrl: /oauth/token
scopes: scopes:
newsletter:list.read: Read newsletter subscriptions by list newsletter:list.read: Read newsletter subscriptions by list
newsletter:list.read.global: Read newsletter subscriptions across tenants
newsletter:subscriptions.read: Read tenant subscription preferences
newsletter:subscriptions.write: Create subscriptions and issue unsubscribe tokens
newsletter:send.write: Create/send newsletter jobs newsletter:send.write: Create/send newsletter jobs
newsletter:send.read: Read newsletter send status newsletter:send.read: Read newsletter send status
newsletter:events.read: Read newsletter events newsletter:events.read: Read newsletter events
newsletter:events.write: Write newsletter events (tenant scoped) newsletter:events.write: Write newsletter events (tenant scoped)
newsletter:events.write.global: Write newsletter events (platform 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: BearerAuth:
type: http type: http
scheme: bearer scheme: bearer
@ -859,16 +908,6 @@ components:
email: { type: string, format: email } email: { type: string, format: email }
password: { type: string } password: { type: string }
PasswordTokenRequest:
type: object
required: [grant_type, username, password]
properties:
grant_type: { type: string, enum: [password] }
username: { type: string, format: email }
password: { type: string }
scope: { type: string }
client_id: { type: string }
AuthorizationCodeTokenRequest: AuthorizationCodeTokenRequest:
type: object type: object
required: [grant_type, code, redirect_uri, code_verifier] required: [grant_type, code, redirect_uri, code_verifier]
@ -917,6 +956,52 @@ components:
token: { type: string } token: { type: string }
new_password: { 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: UserProfile:
type: object type: object
properties: properties:
@ -1002,7 +1087,7 @@ components:
required: [event_id, event_type, tenant_id, list_id, subscriber, occurred_at] required: [event_id, event_type, tenant_id, list_id, subscriber, occurred_at]
properties: properties:
event_id: { type: string } 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 } tenant_id: { type: string }
list_id: { type: string } list_id: { type: string }
subscriber: subscriber:
@ -1071,6 +1156,6 @@ components:
type: string type: string
nullable: true nullable: true
name: { type: string } 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 } } redirect_uris: { type: array, items: { type: string } }
client_type: { type: string, enum: [public, confidential] } client_type: { type: string, enum: [public, confidential] }

View File

@ -0,0 +1,40 @@
using MemberCenter.Application.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace MemberCenter.Api.Authorization;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class AdminPermissionAttribute : TypeFilterAttribute
{
public AdminPermissionAttribute(string permissionKey)
: base(typeof(AdminPermissionFilter))
{
Arguments = [permissionKey];
}
}
public sealed class AdminPermissionFilter : IAsyncAuthorizationFilter
{
private readonly string _permissionKey;
private readonly IAdminPermissionChecker _permissionChecker;
public AdminPermissionFilter(string permissionKey, IAdminPermissionChecker permissionChecker)
{
_permissionKey = permissionKey;
_permissionChecker = permissionChecker;
}
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
var hasPermission = await _permissionChecker.HasPermissionAsync(
context.HttpContext.User,
_permissionKey,
context.HttpContext.RequestAborted);
if (!hasPermission)
{
context.Result = new NotFoundResult();
}
}
}

View File

@ -2,8 +2,6 @@ namespace MemberCenter.Api.Contracts;
public sealed record RegisterRequest(string Email, string Password); public sealed record RegisterRequest(string Email, string Password);
public sealed record LoginRequest(string Email, string Password, string? Scope);
public sealed record RefreshRequest(string RefreshToken); public sealed record RefreshRequest(string RefreshToken);
public sealed record ForgotPasswordRequest(string Email); public sealed record ForgotPasswordRequest(string Email);

View 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);

View File

@ -0,0 +1,33 @@
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,
bool IsDefault,
string? AddressMetaJson);

View File

@ -1,5 +1,7 @@
using MemberCenter.Api.Authorization;
using MemberCenter.Api.Contracts; using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions; using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -8,6 +10,7 @@ namespace MemberCenter.Api.Controllers;
[ApiController] [ApiController]
[Route("admin/newsletter-lists")] [Route("admin/newsletter-lists")]
[Authorize(Policy = "Admin")] [Authorize(Policy = "Admin")]
[AdminPermission(AdminPermissions.NewsletterLists)]
public class AdminNewsletterListsController : ControllerBase public class AdminNewsletterListsController : ControllerBase
{ {
private readonly INewsletterListService _listService; private readonly INewsletterListService _listService;
@ -18,6 +21,7 @@ public class AdminNewsletterListsController : ControllerBase
} }
[HttpGet] [HttpGet]
[AdminPermission(AdminPermissions.NewsletterListsIndex)]
public async Task<IActionResult> List() public async Task<IActionResult> List()
{ {
var lists = await _listService.ListAsync(); var lists = await _listService.ListAsync();
@ -25,6 +29,7 @@ public class AdminNewsletterListsController : ControllerBase
} }
[HttpPost] [HttpPost]
[AdminPermission(AdminPermissions.NewsletterListsCreate)]
public async Task<IActionResult> Create([FromBody] NewsletterListRequest request) public async Task<IActionResult> Create([FromBody] NewsletterListRequest request)
{ {
var list = await _listService.CreateAsync(request.TenantId, request.Name, request.Status); var list = await _listService.CreateAsync(request.TenantId, request.Name, request.Status);
@ -32,6 +37,7 @@ public class AdminNewsletterListsController : ControllerBase
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
[AdminPermission(AdminPermissions.NewsletterListsIndex)]
public async Task<IActionResult> Get(Guid id) public async Task<IActionResult> Get(Guid id)
{ {
var list = await _listService.GetAsync(id); var list = await _listService.GetAsync(id);
@ -44,6 +50,7 @@ public class AdminNewsletterListsController : ControllerBase
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[AdminPermission(AdminPermissions.NewsletterListsEdit)]
public async Task<IActionResult> Update(Guid id, [FromBody] NewsletterListRequest request) public async Task<IActionResult> Update(Guid id, [FromBody] NewsletterListRequest request)
{ {
var list = await _listService.UpdateAsync(id, request.TenantId, request.Name, request.Status); var list = await _listService.UpdateAsync(id, request.TenantId, request.Name, request.Status);
@ -55,6 +62,7 @@ public class AdminNewsletterListsController : ControllerBase
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[AdminPermission(AdminPermissions.NewsletterListsDelete)]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var deleted = await _listService.DeleteAsync(id); var deleted = await _listService.DeleteAsync(id);

View File

@ -1,4 +1,7 @@
using MemberCenter.Api.Authorization;
using MemberCenter.Api.Contracts; using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
@ -9,16 +12,22 @@ namespace MemberCenter.Api.Controllers;
[ApiController] [ApiController]
[Route("admin/oauth-clients")] [Route("admin/oauth-clients")]
[Authorize(Policy = "Admin")] [Authorize(Policy = "Admin")]
[AdminPermission(AdminPermissions.OAuthClients)]
public class AdminOAuthClientsController : ControllerBase public class AdminOAuthClientsController : ControllerBase
{ {
private readonly IOpenIddictApplicationManager _applicationManager; private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IAuthResourceRegistryService _authResourceRegistry;
public AdminOAuthClientsController(IOpenIddictApplicationManager applicationManager) public AdminOAuthClientsController(
IOpenIddictApplicationManager applicationManager,
IAuthResourceRegistryService authResourceRegistry)
{ {
_applicationManager = applicationManager; _applicationManager = applicationManager;
_authResourceRegistry = authResourceRegistry;
} }
[HttpGet] [HttpGet]
[AdminPermission(AdminPermissions.OAuthClientsIndex)]
public async Task<IActionResult> List() public async Task<IActionResult> List()
{ {
var results = new List<object>(); var results = new List<object>();
@ -39,11 +48,12 @@ public class AdminOAuthClientsController : ControllerBase
} }
[HttpPost] [HttpPost]
[AdminPermission(AdminPermissions.OAuthClientsCreate)]
public async Task<IActionResult> Create([FromBody] OAuthClientRequest request) public async Task<IActionResult> Create([FromBody] OAuthClientRequest request)
{ {
if (!IsValidUsage(request.Usage)) 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)) if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
@ -64,7 +74,7 @@ public class AdminOAuthClientsController : ControllerBase
} }
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0) 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 var descriptor = new OpenIddictApplicationDescriptor
@ -73,7 +83,15 @@ public class AdminOAuthClientsController : ControllerBase
DisplayName = request.Name, DisplayName = request.Name,
ClientType = request.ClientType ClientType = request.ClientType
}; };
ApplyPermissions(descriptor, request.Usage); var clientSecret = string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)
? GenerateClientSecret()
: null;
if (!string.IsNullOrWhiteSpace(clientSecret))
{
descriptor.ClientSecret = clientSecret;
}
await ApplyPermissionsAsync(descriptor, request.Usage);
foreach (var uri in redirectUris) foreach (var uri in redirectUris)
{ {
@ -93,11 +111,13 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.ClientId, descriptor.ClientId,
descriptor.DisplayName, descriptor.DisplayName,
descriptor.ClientType, descriptor.ClientType,
client_secret = clientSecret,
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString()) redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
}); });
} }
[HttpGet("{id}")] [HttpGet("{id}")]
[AdminPermission(AdminPermissions.OAuthClientsIndex)]
public async Task<IActionResult> Get(string id) public async Task<IActionResult> Get(string id)
{ {
var app = await _applicationManager.FindByIdAsync(id); var app = await _applicationManager.FindByIdAsync(id);
@ -118,11 +138,12 @@ public class AdminOAuthClientsController : ControllerBase
} }
[HttpPut("{id}")] [HttpPut("{id}")]
[AdminPermission(AdminPermissions.OAuthClientsEdit)]
public async Task<IActionResult> Update(string id, [FromBody] OAuthClientRequest request) public async Task<IActionResult> Update(string id, [FromBody] OAuthClientRequest request)
{ {
if (!IsValidUsage(request.Usage)) 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)) if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
@ -143,7 +164,7 @@ public class AdminOAuthClientsController : ControllerBase
} }
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0) 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); var app = await _applicationManager.FindByIdAsync(id);
@ -155,9 +176,24 @@ public class AdminOAuthClientsController : ControllerBase
var descriptor = new OpenIddictApplicationDescriptor(); var descriptor = new OpenIddictApplicationDescriptor();
await _applicationManager.PopulateAsync(descriptor, app); await _applicationManager.PopulateAsync(descriptor, app);
var originalClientType = await _applicationManager.GetClientTypeAsync(app);
string? generatedClientSecret = null;
descriptor.DisplayName = request.Name; descriptor.DisplayName = request.Name;
descriptor.ClientType = request.ClientType; descriptor.ClientType = request.ClientType;
ApplyPermissions(descriptor, request.Usage); if (string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
{
descriptor.ClientSecret = null;
}
else if (string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)
&& (!string.Equals(originalClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(descriptor.ClientSecret)))
{
generatedClientSecret = GenerateClientSecret();
descriptor.ClientSecret = generatedClientSecret;
}
await ApplyPermissionsAsync(descriptor, request.Usage);
descriptor.RedirectUris.Clear(); descriptor.RedirectUris.Clear();
foreach (var uri in redirectUris) foreach (var uri in redirectUris)
{ {
@ -180,11 +216,13 @@ public class AdminOAuthClientsController : ControllerBase
id, id,
descriptor.DisplayName, descriptor.DisplayName,
descriptor.ClientType, descriptor.ClientType,
client_secret = generatedClientSecret,
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString()) redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
}); });
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[AdminPermission(AdminPermissions.OAuthClientsDelete)]
public async Task<IActionResult> Delete(string id) public async Task<IActionResult> Delete(string id)
{ {
var app = await _applicationManager.FindByIdAsync(id); var app = await _applicationManager.FindByIdAsync(id);
@ -201,27 +239,35 @@ public class AdminOAuthClientsController : ControllerBase
{ {
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase) return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_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, "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) 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) private static bool RequiresClientCredentials(string usage)
{ {
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "tenant_api", 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) 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 string GenerateClientSecret() =>
Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
private static (List<string> Uris, string? Error) NormalizeRedirectUris(List<string>? redirectUris) private static (List<string> Uris, string? Error) NormalizeRedirectUris(List<string>? redirectUris)
{ {
if (redirectUris is null || redirectUris.Count == 0) if (redirectUris is null || redirectUris.Count == 0)
@ -246,38 +292,27 @@ public class AdminOAuthClientsController : ControllerBase
return (values, null); return (values, null);
} }
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage) private async Task ApplyPermissionsAsync(OpenIddictApplicationDescriptor descriptor, string usage)
{ {
descriptor.Permissions.Clear(); descriptor.Permissions.Clear();
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) if (UsesAuthorizationCodeFlow(usage))
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
{ {
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:events.write.global");
}
else if (string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:send.write");
descriptor.Permissions.Add("scp:newsletter:send.read");
}
else
{
descriptor.Permissions.Add("scp:newsletter:events.write");
}
return;
}
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode); descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken); descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code); descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email); }
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile); else
descriptor.Permissions.Add("scp:openid"); {
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
}
var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted);
foreach (var scope in scopes)
{
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + scope);
}
} }
} }

View File

@ -1,5 +1,7 @@
using MemberCenter.Api.Authorization;
using MemberCenter.Api.Contracts; using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions; using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -8,6 +10,7 @@ namespace MemberCenter.Api.Controllers;
[ApiController] [ApiController]
[Route("admin/tenants")] [Route("admin/tenants")]
[Authorize(Policy = "Admin")] [Authorize(Policy = "Admin")]
[AdminPermission(AdminPermissions.Tenants)]
public class AdminTenantsController : ControllerBase public class AdminTenantsController : ControllerBase
{ {
private readonly ITenantService _tenantService; private readonly ITenantService _tenantService;
@ -18,6 +21,7 @@ public class AdminTenantsController : ControllerBase
} }
[HttpGet] [HttpGet]
[AdminPermission(AdminPermissions.TenantsIndex)]
public async Task<IActionResult> List() public async Task<IActionResult> List()
{ {
var tenants = await _tenantService.ListAsync(); var tenants = await _tenantService.ListAsync();
@ -25,6 +29,7 @@ public class AdminTenantsController : ControllerBase
} }
[HttpPost] [HttpPost]
[AdminPermission(AdminPermissions.TenantsCreate)]
public async Task<IActionResult> Create([FromBody] TenantRequest request) public async Task<IActionResult> Create([FromBody] TenantRequest request)
{ {
var tenant = await _tenantService.CreateAsync(request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId); var tenant = await _tenantService.CreateAsync(request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId);
@ -32,6 +37,7 @@ public class AdminTenantsController : ControllerBase
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
[AdminPermission(AdminPermissions.TenantsIndex)]
public async Task<IActionResult> Get(Guid id) public async Task<IActionResult> Get(Guid id)
{ {
var tenant = await _tenantService.GetAsync(id); var tenant = await _tenantService.GetAsync(id);
@ -44,6 +50,7 @@ public class AdminTenantsController : ControllerBase
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[AdminPermission(AdminPermissions.TenantsEdit)]
public async Task<IActionResult> Update(Guid id, [FromBody] TenantRequest request) public async Task<IActionResult> Update(Guid id, [FromBody] TenantRequest request)
{ {
var tenant = await _tenantService.UpdateAsync(id, request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId); var tenant = await _tenantService.UpdateAsync(id, request.Name, request.Domains, request.Status, request.SendEngineWebhookClientId);
@ -55,6 +62,7 @@ public class AdminTenantsController : ControllerBase
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[AdminPermission(AdminPermissions.TenantsDelete)]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var deleted = await _tenantService.DeleteAsync(id); var deleted = await _tenantService.DeleteAsync(id);

View File

@ -1,8 +1,11 @@
using MemberCenter.Api.Contracts; using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MemberCenter.Api.Controllers; namespace MemberCenter.Api.Controllers;
@ -10,42 +13,53 @@ namespace MemberCenter.Api.Controllers;
[Route("auth")] [Route("auth")]
public class AuthController : ControllerBase public class AuthController : ControllerBase
{ {
private readonly IAccountProvisioningService _accountProvisioningService;
private readonly IAccountEmailService _accountEmailService;
private readonly IAuditLogWriter _auditLogWriter;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager; 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; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
} }
[HttpPost("register")] [HttpPost("register")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)]
public async Task<IActionResult> Register([FromBody] RegisterRequest request) public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{ {
var user = new ApplicationUser var result = await _accountProvisioningService.RegisterLocalAsync(request.Email, request.Password);
{
Id = Guid.NewGuid(),
UserName = request.Email,
Email = request.Email,
EmailConfirmed = false
};
var result = await _userManager.CreateAsync(user, request.Password);
if (!result.Succeeded) 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 return Ok(new
{ {
id = user.Id, id = result.UserId,
email = user.Email, email = result.Email,
email_verified = user.EmailConfirmed, email_verified = result.EmailConfirmed,
created_at = user.CreatedAt linked_subscriptions = result.LinkedSubscriptionsCount
}); });
} }
[HttpPost("password/forgot")] [HttpPost("password/forgot")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request) public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
{ {
var user = await _userManager.FindByEmailAsync(request.Email); var user = await _userManager.FindByEmailAsync(request.Email);
@ -54,11 +68,12 @@ public class AuthController : ControllerBase
return NoContent(); return NoContent();
} }
var token = await _userManager.GeneratePasswordResetTokenAsync(user); await _accountEmailService.SendPasswordResetEmailAsync(user.Id, GetBaseUrl());
return Ok(new { token }); return NoContent();
} }
[HttpPost("password/reset")] [HttpPost("password/reset")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request) public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
{ {
var user = await _userManager.FindByEmailAsync(request.Email); var user = await _userManager.FindByEmailAsync(request.Email);
@ -73,10 +88,16 @@ public class AuthController : ControllerBase
return BadRequest(result.Errors.Select(e => e.Description)); 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(); return NoContent();
} }
[HttpGet("email/verify")] [HttpGet("email/verify")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
public async Task<IActionResult> VerifyEmail([FromQuery] string token, [FromQuery] string email) public async Task<IActionResult> VerifyEmail([FromQuery] string token, [FromQuery] string email)
{ {
var user = await _userManager.FindByEmailAsync(email); var user = await _userManager.FindByEmailAsync(email);
@ -91,9 +112,33 @@ public class AuthController : ControllerBase
return BadRequest(result.Errors.Select(e => e.Description)); 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" }); 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] [Authorize]
[HttpPost("logout")] [HttpPost("logout")]
public async Task<IActionResult> Logout([FromBody] LogoutRequest request) public async Task<IActionResult> Logout([FromBody] LogoutRequest request)
@ -101,4 +146,6 @@ public class AuthController : ControllerBase
await _signInManager.SignOutAsync(); await _signInManager.SignOutAsync();
return NoContent(); return NoContent();
} }
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
} }

View File

@ -0,0 +1,208 @@
using System.Security.Cryptography;
using System.Text;
using MemberCenter.Api.Contracts;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Configuration;
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;
using Microsoft.Extensions.Options;
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 readonly MemberCenterDbContext _dbContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly FileAccessTokenOptions _tokenOptions;
public FileAccessController(
MemberCenterDbContext dbContext,
UserManager<ApplicationUser> userManager,
IOptions<FileAccessTokenOptions> tokenOptions)
{
_dbContext = dbContext;
_userManager = userManager;
_tokenOptions = tokenOptions.Value;
}
[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 Forbid();
}
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 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 TimeSpan ResolveLifetime(int? expiresInSeconds)
{
if (!expiresInSeconds.HasValue)
{
return TimeSpan.FromSeconds(_tokenOptions.DefaultLifetimeSeconds);
}
var requested = TimeSpan.FromSeconds(expiresInSeconds.Value);
var minimum = TimeSpan.FromSeconds(_tokenOptions.MinimumLifetimeSeconds);
if (requested < minimum)
{
return minimum;
}
var maximum = TimeSpan.FromSeconds(_tokenOptions.MaximumLifetimeSeconds);
return requested > maximum ? maximum : 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();
}
}

View File

@ -1,8 +1,12 @@
using MemberCenter.Api.Contracts; using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions; using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Infrastructure.Configuration;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using Microsoft.Extensions.Options;
namespace MemberCenter.Api.Controllers; namespace MemberCenter.Api.Controllers;
@ -11,15 +15,30 @@ namespace MemberCenter.Api.Controllers;
public class NewsletterController : ControllerBase public class NewsletterController : ControllerBase
{ {
private readonly INewsletterService _newsletterService; private readonly INewsletterService _newsletterService;
private readonly INewsletterListService _newsletterListService;
private readonly NewsletterTokenOptions _tokenOptions;
public NewsletterController(INewsletterService newsletterService) public NewsletterController(
INewsletterService newsletterService,
INewsletterListService newsletterListService,
IOptions<NewsletterTokenOptions> tokenOptions)
{ {
_newsletterService = newsletterService; _newsletterService = newsletterService;
_newsletterListService = newsletterListService;
_tokenOptions = tokenOptions.Value;
} }
[HttpPost("subscribe")] [HttpPost("subscribe")]
[Authorize]
[EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterSubscribe)]
public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request) public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request)
{ {
var authorizationFailure = await ValidateTenantListAccessAsync(request.ListId, "newsletter:subscriptions.write");
if (authorizationFailure is not null)
{
return authorizationFailure;
}
var result = await _newsletterService.SubscribeAsync(request.ListId, request.Email, request.Preferences); var result = await _newsletterService.SubscribeAsync(request.ListId, request.Email, request.Preferences);
if (result is null) if (result is null)
{ {
@ -76,6 +95,8 @@ public class NewsletterController : ControllerBase
} }
[HttpPost("unsubscribe-token")] [HttpPost("unsubscribe-token")]
[Authorize]
[EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterUnsubscribeToken)]
public async Task<IActionResult> IssueUnsubscribeToken([FromBody] IssueUnsubscribeTokenRequest request) public async Task<IActionResult> IssueUnsubscribeToken([FromBody] IssueUnsubscribeTokenRequest request)
{ {
if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email)) if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email))
@ -83,6 +104,12 @@ public class NewsletterController : ControllerBase
return BadRequest("Both list_id and email are required."); return BadRequest("Both list_id and email are required.");
} }
var authorizationFailure = await ValidateTenantListAccessAsync(request.ListId, "newsletter:subscriptions.write");
if (authorizationFailure is not null)
{
return authorizationFailure;
}
var token = await _newsletterService.IssueUnsubscribeTokenAsync(request.ListId, request.Email); var token = await _newsletterService.IssueUnsubscribeTokenAsync(request.ListId, request.Email);
if (token is null) if (token is null)
{ {
@ -96,6 +123,7 @@ public class NewsletterController : ControllerBase
} }
[Authorize] [Authorize]
[EnableRateLimiting(RateLimitPolicyNames.NewsletterOneClickToken)]
[HttpPost("one-click-unsubscribe-token")] [HttpPost("one-click-unsubscribe-token")]
public async Task<IActionResult> IssueOneClickUnsubscribeToken([FromBody] IssueOneClickUnsubscribeTokenRequest request) public async Task<IActionResult> IssueOneClickUnsubscribeToken([FromBody] IssueOneClickUnsubscribeTokenRequest request)
{ {
@ -111,9 +139,9 @@ public class NewsletterController : ControllerBase
return BadRequest("tenant_id, list_id, subscriber_id are required."); return BadRequest("tenant_id, list_id, subscriber_id are required.");
} }
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId) if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != request.TenantId))
{ {
return BadRequest("tenant_id does not match token tenant scope."); return Forbid();
} }
var token = await _newsletterService.IssueOneClickUnsubscribeTokenAsync(request.TenantId, request.ListId, request.SubscriberId); var token = await _newsletterService.IssueOneClickUnsubscribeTokenAsync(request.TenantId, request.ListId, request.SubscriberId);
@ -129,6 +157,7 @@ public class NewsletterController : ControllerBase
} }
[Authorize] [Authorize]
[EnableRateLimiting(RateLimitPolicyNames.NewsletterOneClickToken)]
[HttpPost("one-click-unsubscribe-tokens")] [HttpPost("one-click-unsubscribe-tokens")]
public async Task<IActionResult> IssueOneClickUnsubscribeTokens([FromBody] IssueOneClickUnsubscribeTokensRequest request) public async Task<IActionResult> IssueOneClickUnsubscribeTokens([FromBody] IssueOneClickUnsubscribeTokensRequest request)
{ {
@ -144,14 +173,14 @@ public class NewsletterController : ControllerBase
return BadRequest("tenant_id, list_id, subscriber_ids are required."); return BadRequest("tenant_id, list_id, subscriber_ids are required.");
} }
if (request.SubscriberIds.Count > 1000) if (request.SubscriberIds.Count > _tokenOptions.OneClickBatchSizeLimit)
{ {
return BadRequest("subscriber_ids exceeds maximum batch size (1000)."); return BadRequest($"subscriber_ids exceeds maximum batch size ({_tokenOptions.OneClickBatchSizeLimit}).");
} }
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId) if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != request.TenantId))
{ {
return BadRequest("tenant_id does not match token tenant scope."); return Forbid();
} }
var items = await _newsletterService.IssueOneClickUnsubscribeTokensAsync(request.TenantId, request.ListId, request.SubscriberIds); var items = await _newsletterService.IssueOneClickUnsubscribeTokensAsync(request.TenantId, request.ListId, request.SubscriberIds);
@ -167,6 +196,7 @@ public class NewsletterController : ControllerBase
} }
[HttpGet("preferences")] [HttpGet("preferences")]
[Authorize]
public async Task<IActionResult> Preferences([FromQuery(Name = "list_id")] Guid? listId, [FromQuery] string? email) public async Task<IActionResult> Preferences([FromQuery(Name = "list_id")] Guid? listId, [FromQuery] string? email)
{ {
if (!listId.HasValue || listId.Value == Guid.Empty || string.IsNullOrWhiteSpace(email)) if (!listId.HasValue || listId.Value == Guid.Empty || string.IsNullOrWhiteSpace(email))
@ -174,6 +204,12 @@ public class NewsletterController : ControllerBase
return BadRequest("Both list_id and email are required."); return BadRequest("Both list_id and email are required.");
} }
var authorizationFailure = await ValidateTenantListAccessAsync(listId.Value, "newsletter:subscriptions.read");
if (authorizationFailure is not null)
{
return authorizationFailure;
}
var subscription = await _newsletterService.GetPreferencesAsync(listId.Value, email); var subscription = await _newsletterService.GetPreferencesAsync(listId.Value, email);
if (subscription is null) if (subscription is null)
{ {
@ -191,6 +227,7 @@ public class NewsletterController : ControllerBase
} }
[HttpPost("preferences")] [HttpPost("preferences")]
[Authorize]
public async Task<IActionResult> UpdatePreferences([FromBody] UpdatePreferencesRequest request) public async Task<IActionResult> UpdatePreferences([FromBody] UpdatePreferencesRequest request)
{ {
if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email)) if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email))
@ -198,6 +235,12 @@ public class NewsletterController : ControllerBase
return BadRequest("Both list_id and email are required."); return BadRequest("Both list_id and email are required.");
} }
var authorizationFailure = await ValidateTenantListAccessAsync(request.ListId, "newsletter:subscriptions.write");
if (authorizationFailure is not null)
{
return authorizationFailure;
}
var subscription = await _newsletterService.UpdatePreferencesAsync(request.ListId, request.Email, request.Preferences); var subscription = await _newsletterService.UpdatePreferencesAsync(request.ListId, request.Email, request.Preferences);
if (subscription is null) if (subscription is null)
{ {
@ -218,7 +261,9 @@ public class NewsletterController : ControllerBase
[HttpGet("subscriptions")] [HttpGet("subscriptions")]
public async Task<IActionResult> ListSubscriptions([FromQuery(Name = "list_id")] Guid listId) public async Task<IActionResult> ListSubscriptions([FromQuery(Name = "list_id")] Guid listId)
{ {
if (!HasScope(User, "newsletter:list.read")) var hasTenantScope = HasScope(User, "newsletter:list.read");
var hasGlobalScope = HasScope(User, "newsletter:list.read.global");
if (!hasTenantScope && !hasGlobalScope)
{ {
return Forbid(); return Forbid();
} }
@ -228,6 +273,17 @@ public class NewsletterController : ControllerBase
return BadRequest("list_id is required."); return BadRequest("list_id is required.");
} }
var list = await _newsletterListService.GetAsync(listId);
if (list is null)
{
return NotFound();
}
if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != list.TenantId))
{
return Forbid();
}
var subscriptions = await _newsletterService.ListSubscriptionsAsync(listId); var subscriptions = await _newsletterService.ListSubscriptionsAsync(listId);
return Ok(subscriptions.Select(s => new return Ok(subscriptions.Select(s => new
{ {
@ -247,6 +303,29 @@ public class NewsletterController : ControllerBase
return values.Contains(scope, StringComparer.Ordinal); return values.Contains(scope, StringComparer.Ordinal);
} }
private async Task<IActionResult?> ValidateTenantListAccessAsync(Guid listId, string scope)
{
if (!HasScope(User, scope))
{
return Forbid();
}
if (listId == Guid.Empty)
{
return BadRequest("list_id is required.");
}
var list = await _newsletterListService.GetAsync(listId);
if (list is null)
{
return NotFound("List not found.");
}
return TryGetTenantId(User, out var tenantId) && tenantId == list.TenantId
? null
: Forbid();
}
private static bool TryGetTenantId(System.Security.Claims.ClaimsPrincipal user, out Guid tenantId) private static bool TryGetTenantId(System.Security.Claims.ClaimsPrincipal user, out Guid tenantId)
{ {
tenantId = Guid.Empty; tenantId = Guid.Empty;

View File

@ -1,28 +1,41 @@
using MemberCenter.Api.Extensions; using MemberCenter.Api.Extensions;
using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore; using OpenIddict.Server.AspNetCore;
using System.Security.Claims; using System.Text.Json;
using System.Web;
namespace MemberCenter.Api.Controllers; namespace MemberCenter.Api.Controllers;
[ApiController] [ApiController]
public class OAuthController : ControllerBase public class OAuthController : ControllerBase
{ {
private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp";
private readonly string _webLoginUrl;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager; private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IAuthResourceRegistryService _authResourceRegistry;
public OAuthController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager) public OAuthController(
IConfiguration configuration,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IOpenIddictApplicationManager applicationManager,
IAuthResourceRegistryService authResourceRegistry)
{ {
_webLoginUrl = configuration["Auth:WebLoginUrl"] ?? "/account/login";
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
_applicationManager = applicationManager;
_authResourceRegistry = authResourceRegistry;
} }
[HttpGet("/oauth/authorize")] [HttpGet("/oauth/authorize")]
[Authorize]
public async Task<IActionResult> Authorize() public async Task<IActionResult> Authorize()
{ {
var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.Transaction?.Request; var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.Transaction?.Request;
@ -31,14 +44,50 @@ public class OAuthController : ControllerBase
return BadRequest("Invalid OpenIddict request."); 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) if (user is null)
{ {
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
} }
if (user.DisabledAt.HasValue)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var application = string.IsNullOrWhiteSpace(request.ClientId)
? null
: await _applicationManager.FindByClientIdAsync(request.ClientId);
if (application is null)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var properties = await _applicationManager.GetPropertiesAsync(application);
var usage = properties.TryGetValue("usage", out var usageValue)
? usageValue.ValueKind == JsonValueKind.String ? usageValue.GetString() : usageValue.ToString()
: null;
if (string.IsNullOrWhiteSpace(usage))
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted);
var principal = await _signInManager.CreateUserPrincipalAsync(user); var principal = await _signInManager.CreateUserPrincipalAsync(user);
principal.SetScopes(request.GetScopes()); principal.SetClaim(OpenIddictConstants.Claims.Subject, user.Id.ToString());
if (!string.IsNullOrWhiteSpace(user.SecurityStamp))
{
principal.SetClaim(SecurityStampClaimType, user.SecurityStamp);
}
principal.SetScopes(scopes);
principal.SetResources(await _authResourceRegistry.ResolveAudiencesAsync(scopes, HttpContext.RequestAborted));
foreach (var claim in principal.Claims) foreach (var claim in principal.Claims)
{ {
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim)); claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
@ -46,4 +95,11 @@ public class OAuthController : ControllerBase
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); 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)}";
}
} }

View File

@ -74,6 +74,11 @@ public class SubscriptionsController : ControllerBase
return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression."); return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression.");
} }
if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != request.TenantId))
{
return Forbid();
}
// TEST-ONLY BEHAVIOR: in dry-run mode, do not execute DB read/write; only emit planned operations. // TEST-ONLY BEHAVIOR: in dry-run mode, do not execute DB read/write; only emit planned operations.
if (_configuration.GetValue<bool>(DisableSubscriptionDryRunNoDbKey)) if (_configuration.GetValue<bool>(DisableSubscriptionDryRunNoDbKey))
{ {
@ -102,11 +107,6 @@ public class SubscriptionsController : ControllerBase
}); });
} }
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
{
return BadRequest("tenant_id does not match token tenant scope.");
}
var target = await ( var target = await (
from subscription in _dbContext.NewsletterSubscriptions from subscription in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id

View File

@ -1,8 +1,11 @@
using MemberCenter.Api.Extensions; using MemberCenter.Api.Extensions;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore; using OpenIddict.Server.AspNetCore;
using System.Security.Claims; using System.Security.Claims;
@ -13,28 +16,26 @@ namespace MemberCenter.Api.Controllers;
[ApiController] [ApiController]
public class TokenController : ControllerBase public class TokenController : ControllerBase
{ {
private readonly string _memberCenterAudience; private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp";
private readonly string _sendEngineAudience;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IOpenIddictApplicationManager _applicationManager; private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IAuthResourceRegistryService _authResourceRegistry;
private readonly ISecuritySettingsService _securitySettingsService;
public TokenController( public TokenController(
IConfiguration configuration,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager, IOpenIddictApplicationManager applicationManager,
IOpenIddictApplicationManager applicationManager) IAuthResourceRegistryService authResourceRegistry,
ISecuritySettingsService securitySettingsService)
{ {
_memberCenterAudience = configuration["Auth:MemberCenterAudience"] ?? "member_center_api";
_sendEngineAudience = configuration["Auth:SendEngineAudience"] ?? "send_engine_api";
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager;
_applicationManager = applicationManager; _applicationManager = applicationManager;
_authResourceRegistry = authResourceRegistry;
_securitySettingsService = securitySettingsService;
} }
[HttpPost("/oauth/token")] [HttpPost("/oauth/token")]
[HttpPost("/auth/login")] [EnableRateLimiting(RateLimitPolicyNames.OAuthToken)]
[HttpPost("/auth/refresh")]
public async Task<IActionResult> Exchange() public async Task<IActionResult> Exchange()
{ {
var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.Transaction?.Request; var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.Transaction?.Request;
@ -43,34 +44,7 @@ public class TokenController : ControllerBase
return BadRequest("Invalid OpenIddict request."); return BadRequest("Invalid OpenIddict request.");
} }
if (request.IsPasswordGrantType()) if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
{
var user = await _userManager.FindByEmailAsync(request.Username ?? string.Empty);
if (user is null)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var valid = await _userManager.CheckPasswordAsync(user, request.Password ?? string.Empty);
if (!valid)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var principal = await _signInManager.CreateUserPrincipalAsync(user);
var scopes = request.Scope.GetScopesOrDefault();
principal.SetScopes(scopes);
principal.SetResources(ResolveResources(scopes));
foreach (var claim in principal.Claims)
{
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
}
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (request.IsRefreshTokenGrantType())
{ {
var authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); var authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
if (!authenticateResult.Succeeded || authenticateResult.Principal is null) if (!authenticateResult.Succeeded || authenticateResult.Principal is null)
@ -79,6 +53,12 @@ public class TokenController : ControllerBase
} }
var principal = authenticateResult.Principal; var principal = authenticateResult.Principal;
if (!await ValidateUserPrincipalAsync(principal))
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
await ApplyTokenLifetimesAsync(principal);
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
} }
@ -115,9 +95,10 @@ public class TokenController : ControllerBase
} }
} }
string? usage = null;
if (properties.TryGetValue("usage", out var usageValue)) if (properties.TryGetValue("usage", out var usageValue))
{ {
var usage = usageValue.ValueKind == JsonValueKind.String usage = usageValue.ValueKind == JsonValueKind.String
? usageValue.GetString() ? usageValue.GetString()
: usageValue.ToString(); : usageValue.ToString();
if (!string.IsNullOrWhiteSpace(usage)) if (!string.IsNullOrWhiteSpace(usage))
@ -126,50 +107,59 @@ public class TokenController : ControllerBase
} }
} }
if (string.IsNullOrWhiteSpace(usage))
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var principal = new ClaimsPrincipal(identity); var principal = new ClaimsPrincipal(identity);
var scopes = request.Scope.GetScopesOrDefault(); var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted);
principal.SetScopes(scopes); principal.SetScopes(scopes);
principal.SetResources(ResolveResources(scopes)); principal.SetResources(await ResolveResourcesAsync(scopes));
foreach (var claim in principal.Claims) foreach (var claim in principal.Claims)
{ {
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim)); claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
} }
await ApplyTokenLifetimesAsync(principal);
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
} }
return BadRequest("Unsupported grant type."); 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); var subject = principal.GetClaim(OpenIddictConstants.Claims.Subject)
if (scopeSet.Count == 0) ?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (!Guid.TryParse(subject, out var userId))
{ {
return [_memberCenterAudience]; return true;
} }
var resources = new HashSet<string>(StringComparer.Ordinal); var user = await _userManager.FindByIdAsync(userId.ToString());
if (user is null || user.DisabledAt.HasValue)
if (scopeSet.Contains("newsletter:send.write") || scopeSet.Contains("newsletter:send.read"))
{ {
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") var tokenSecurityStamp = principal.FindFirst(SecurityStampClaimType)?.Value;
|| scopeSet.Contains(OpenIddictConstants.Scopes.OpenId) return string.IsNullOrWhiteSpace(tokenSecurityStamp)
|| scopeSet.Contains(OpenIddictConstants.Scopes.Email) || string.Equals(tokenSecurityStamp, user.SecurityStamp, StringComparison.Ordinal);
|| scopeSet.Contains(OpenIddictConstants.Scopes.Profile))
{
resources.Add(_memberCenterAudience);
} }
if (resources.Count == 0) private async Task<IReadOnlyList<string>> ResolveResourcesAsync(IEnumerable<string> scopes)
{ {
resources.Add(_memberCenterAudience); return await _authResourceRegistry.ResolveAudiencesAsync(scopes, HttpContext.RequestAborted);
} }
return resources; private async Task ApplyTokenLifetimesAsync(ClaimsPrincipal principal)
{
var settings = await _securitySettingsService.GetTokenLifetimeSettingsAsync();
var accessMinutes = Math.Clamp(settings.AccessTokenMinutes, 5, 1440);
var refreshDays = Math.Clamp(settings.RefreshTokenDays, 1, 365);
principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(accessMinutes));
principal.SetRefreshTokenLifetime(TimeSpan.FromDays(refreshDays));
} }
} }

View File

@ -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 MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@ -9,14 +13,21 @@ namespace MemberCenter.Api.Controllers;
[Route("user")] [Route("user")]
public class UserController : ControllerBase public class UserController : ControllerBase
{ {
private readonly IProfileService _profileService;
private readonly INewsletterService _newsletterService;
private readonly UserManager<ApplicationUser> _userManager; 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; _userManager = userManager;
} }
[Authorize] [Authorize(Policy = "ProfileBasicRead")]
[HttpGet("profile")] [HttpGet("profile")]
public async Task<IActionResult> Profile() public async Task<IActionResult> Profile()
{ {
@ -26,12 +37,145 @@ public class UserController : ControllerBase
return Unauthorized(); return Unauthorized();
} }
var profile = await _profileService.GetProfileAsync(user.Id);
return Ok(new return Ok(new
{ {
id = user.Id, id = user.Id,
email = user.Email, email = user.Email,
email_verified = user.EmailConfirmed, 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.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);
}
} }

View File

@ -1,24 +1,10 @@
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using System.Security.Claims;
namespace MemberCenter.Api.Extensions; namespace MemberCenter.Api.Extensions;
public static class ClaimsExtensions public static class ClaimsExtensions
{ {
public static IEnumerable<string> GetScopesOrDefault(this string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return new[]
{
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile
};
}
return scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
public static IEnumerable<string> GetDestinations(System.Security.Claims.Claim claim) public static IEnumerable<string> GetDestinations(System.Security.Claims.Claim claim)
{ {
return claim.Type switch return claim.Type switch
@ -28,4 +14,11 @@ public static class ClaimsExtensions
_ => new[] { OpenIddictConstants.Destinations.AccessToken } _ => 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);
}
} }

View File

@ -1,10 +1,16 @@
using System.Security.Claims;
using System.Threading.RateLimiting;
using MemberCenter.Infrastructure.Configuration; using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence; using MemberCenter.Infrastructure.Persistence;
using MemberCenter.Infrastructure.Services; using MemberCenter.Infrastructure.Services;
using MemberCenter.Application.Abstractions; using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore; using OpenIddict.Server.AspNetCore;
@ -16,13 +22,74 @@ var builder = WebApplication.CreateBuilder(args);
var pathBase = NormalizePathBase(builder.Configuration["PathBase"]); var pathBase = NormalizePathBase(builder.Configuration["PathBase"]);
var issuer = builder.Configuration["Auth:Issuer"]; var issuer = builder.Configuration["Auth:Issuer"];
var issuerUri = ParseAbsoluteUriOrThrow(issuer, "Auth:Issuer"); var issuerUri = ParseAbsoluteUriOrThrow(issuer, "Auth:Issuer");
var allowInsecureHttp = builder.Configuration.GetValue("Auth:AllowInsecureHttp", false); var allowInternalHttpTokenEndpoint = builder.Configuration.GetValue("Auth:AllowInternalHttpTokenEndpoint", false);
var identitySecurity = builder.Configuration.GetSection("IdentitySecurity").Get<IdentitySecurityOptions>() ?? new();
var rateLimits = builder.Configuration.GetSection("RateLimits").Get<RateLimitSettings>() ?? new();
var newsletterTokens = builder.Configuration.GetSection("NewsletterTokens").Get<NewsletterTokenOptions>() ?? new();
var fileAccessTokens = builder.Configuration.GetSection("FileAccessTokens").Get<FileAccessTokenOptions>() ?? new();
SecurityRuntimeOptionsValidator.Validate(identitySecurity);
SecurityRuntimeOptionsValidator.Validate(rateLimits);
SecurityRuntimeOptionsValidator.Validate(newsletterTokens);
SecurityRuntimeOptionsValidator.Validate(fileAccessTokens);
var connectionString = builder.Configuration.GetConnectionString("Default");
if (string.IsNullOrWhiteSpace(connectionString))
{
if (!builder.Environment.IsDevelopment())
{
throw new InvalidOperationException("ConnectionStrings:Default is required outside Development.");
}
connectionString = "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
}
var requireProductionCertificates = !builder.Environment.IsDevelopment();
var signingCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync(
builder.Configuration,
connectionString,
"Auth:Certificates:Signing",
CertificateLoader.OpenIddictSigningKey,
requireProductionCertificates);
var previousSigningCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "Auth:Certificates:Signing");
var encryptionCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync(
builder.Configuration,
connectionString,
"Auth:Certificates:Encryption",
CertificateLoader.OpenIddictEncryptionKey,
requireProductionCertificates);
var previousEncryptionCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "Auth:Certificates:Encryption");
var dataProtectionCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync(
builder.Configuration,
connectionString,
"DataProtection:Certificate",
CertificateLoader.DataProtectionKey,
requireProductionCertificates);
var previousDataProtectionCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "DataProtection:Certificate");
if (!builder.Environment.IsDevelopment() && issuerUri is null)
{
throw new InvalidOperationException("Auth:Issuer is required outside Development.");
}
if (!builder.Environment.IsDevelopment() && issuerUri?.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException("Auth:Issuer must use HTTPS outside Development.");
}
var dataProtectionBuilder = builder.Services.AddDataProtection()
.SetApplicationName("MemberCenter")
.PersistKeysToDbContext<MemberCenterDbContext>();
if (dataProtectionCertificate is not null)
{
dataProtectionBuilder.ProtectKeysWithCertificate(dataProtectionCertificate);
if (previousDataProtectionCertificates.Count > 0)
{
dataProtectionBuilder.UnprotectKeysWithAnyCertificate(
new[] { dataProtectionCertificate }.Concat(previousDataProtectionCertificates).ToArray());
}
}
builder.Services.AddDbContext<MemberCenterDbContext>(options => builder.Services.AddDbContext<MemberCenterDbContext>(options =>
{ {
var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString); options.UseNpgsql(connectionString);
options.UseOpenIddict(); options.UseOpenIddict();
}); });
@ -31,11 +98,14 @@ builder.Services
.AddIdentity<ApplicationUser, ApplicationRole>(options => .AddIdentity<ApplicationUser, ApplicationRole>(options =>
{ {
options.User.RequireUniqueEmail = true; options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = true; options.Password.RequireDigit = identitySecurity.Password.RequireDigit;
options.Password.RequireLowercase = true; options.Password.RequireLowercase = identitySecurity.Password.RequireLowercase;
options.Password.RequireUppercase = true; options.Password.RequireUppercase = identitySecurity.Password.RequireUppercase;
options.Password.RequireNonAlphanumeric = false; options.Password.RequireNonAlphanumeric = identitySecurity.Password.RequireNonAlphanumeric;
options.Password.RequiredLength = 8; options.Password.RequiredLength = identitySecurity.Password.RequiredLength;
options.Lockout.AllowedForNewUsers = identitySecurity.Lockout.AllowedForNewUsers;
options.Lockout.MaxFailedAccessAttempts = identitySecurity.Lockout.MaxFailedAccessAttempts;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(identitySecurity.Lockout.DefaultLockoutMinutes);
}) })
.AddEntityFrameworkStores<MemberCenterDbContext>() .AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders(); .AddDefaultTokenProviders();
@ -47,6 +117,13 @@ builder.Services.AddAuthentication(options =>
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
}); });
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.Path = "/";
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
builder.Services.AddOpenIddict() builder.Services.AddOpenIddict()
.AddCore(options => .AddCore(options =>
{ {
@ -56,10 +133,7 @@ builder.Services.AddOpenIddict()
.AddServer(options => .AddServer(options =>
{ {
options.SetAuthorizationEndpointUris(WithPathBase(pathBase, "/oauth/authorize")); options.SetAuthorizationEndpointUris(WithPathBase(pathBase, "/oauth/authorize"));
options.SetTokenEndpointUris( options.SetTokenEndpointUris(WithPathBase(pathBase, "/oauth/token"));
WithPathBase(pathBase, "/oauth/token"),
WithPathBase(pathBase, "/auth/login"),
WithPathBase(pathBase, "/auth/refresh"));
options.SetLogoutEndpointUris(WithPathBase(pathBase, "/auth/logout")); options.SetLogoutEndpointUris(WithPathBase(pathBase, "/auth/logout"));
if (issuerUri is not null) if (issuerUri is not null)
{ {
@ -69,24 +143,51 @@ builder.Services.AddOpenIddict()
options.AllowAuthorizationCodeFlow() options.AllowAuthorizationCodeFlow()
.RequireProofKeyForCodeExchange(); .RequireProofKeyForCodeExchange();
options.AllowRefreshTokenFlow(); options.AllowRefreshTokenFlow();
options.AllowPasswordFlow();
options.AllowClientCredentialsFlow(); options.AllowClientCredentialsFlow();
options.AcceptAnonymousClients();
options.RegisterScopes( options.RegisterScopes(
OpenIddictConstants.Scopes.OpenId, OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email, OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile, 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:list.read",
"newsletter:list.read.global",
"newsletter:subscriptions.read",
"newsletter:subscriptions.write",
"newsletter:send.write", "newsletter:send.write",
"newsletter:send.read", "newsletter:send.read",
"newsletter:events.read", "newsletter:events.read",
"newsletter:events.write", "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");
if (signingCertificate is not null && encryptionCertificate is not null)
{
foreach (var certificate in previousSigningCertificates)
{
options.AddSigningCertificate(certificate);
}
foreach (var certificate in previousEncryptionCertificates)
{
options.AddEncryptionCertificate(certificate);
}
options.AddSigningCertificate(signingCertificate);
options.AddEncryptionCertificate(encryptionCertificate);
}
else
{
options.AddDevelopmentEncryptionCertificate(); options.AddDevelopmentEncryptionCertificate();
options.AddDevelopmentSigningCertificate(); options.AddDevelopmentSigningCertificate();
}
options.DisableAccessTokenEncryption(); options.DisableAccessTokenEncryption();
var aspNetCore = options.UseAspNetCore() var aspNetCore = options.UseAspNetCore()
@ -95,9 +196,9 @@ builder.Services.AddOpenIddict()
.EnableLogoutEndpointPassthrough() .EnableLogoutEndpointPassthrough()
.EnableStatusCodePagesIntegration(); .EnableStatusCodePagesIntegration();
if (builder.Environment.IsDevelopment() || allowInsecureHttp) if (builder.Environment.IsDevelopment() || allowInternalHttpTokenEndpoint)
{ {
// Allows OIDC/OAuth endpoints to operate behind non-HTTPS internal networks/proxies. // Allows direct OAuth calls over a VPC-private HTTP endpoint while retaining an HTTPS issuer.
aspNetCore.DisableTransportSecurityRequirement(); aspNetCore.DisableTransportSecurityRequirement();
} }
}) })
@ -109,26 +210,91 @@ builder.Services.AddOpenIddict()
builder.Services.AddAuthorization(options => 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 => builder.Services.Configure<ForwardedHeadersOptions>(options =>
{ {
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; TrustedForwardedHeaders.Configure(options, builder.Configuration);
options.KnownNetworks.Clear(); });
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", rateLimits.Api.AuthRegister));
options.AddPolicy(RateLimitPolicyNames.PublicAuthRecovery, context =>
CreateFixedWindowLimiter(context, "api-auth-recovery", rateLimits.Api.AuthRecovery));
options.AddPolicy(RateLimitPolicyNames.PublicAuthTokenConsumption, context =>
CreateFixedWindowLimiter(context, "api-auth-token-consumption", rateLimits.Api.AuthTokenConsumption));
options.AddPolicy(RateLimitPolicyNames.PublicNewsletterSubscribe, context =>
CreateFixedWindowLimiter(context, "api-newsletter-subscribe", rateLimits.Api.NewsletterSubscribe));
options.AddPolicy(RateLimitPolicyNames.PublicNewsletterUnsubscribeToken, context =>
CreateFixedWindowLimiter(context, "api-newsletter-unsubscribe-token", rateLimits.Api.NewsletterUnsubscribeToken));
options.AddPolicy(RateLimitPolicyNames.NewsletterOneClickToken, context =>
CreateFixedWindowLimiter(context, "api-newsletter-one-click-token", rateLimits.Api.NewsletterOneClickToken));
options.AddPolicy(RateLimitPolicyNames.OAuthToken, context =>
CreateFixedWindowLimiter(context, "api-oauth-token", rateLimits.Api.OAuthToken));
}); });
builder.Services.AddControllers(); 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<INewsletterService, NewsletterService>();
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>(); builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
builder.Services.AddScoped<ITenantService, TenantService>(); builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<ITenantManagerService, TenantManagerService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>(); builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>();
builder.Services.AddScoped<IUserConsentService, UserConsentService>();
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
builder.Services.AddScoped<AdminPermissionService>();
builder.Services.AddScoped<IAdminPermissionChecker>(services => services.GetRequiredService<AdminPermissionService>());
builder.Services.AddScoped<IAdminPermissionSeeder>(services => services.GetRequiredService<AdminPermissionService>());
builder.Services.AddHttpContextAccessor();
builder.Services.Configure<NewsletterTokenOptions>(builder.Configuration.GetSection("NewsletterTokens"));
builder.Services.Configure<FileAccessTokenOptions>(builder.Configuration.GetSection("FileAccessTokens"));
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine")); builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>(); builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>(); builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
var app = builder.Build(); var app = builder.Build();
var certificateWarningDays = builder.Configuration.GetValue("Certificates:ExpirationWarningDays", 30);
CertificateLoader.LogExpirationWarning(app.Logger, "OpenIddict signing", signingCertificate, certificateWarningDays);
CertificateLoader.LogExpirationWarning(app.Logger, "OpenIddict encryption", encryptionCertificate, certificateWarningDays);
CertificateLoader.LogExpirationWarning(app.Logger, "Data Protection", dataProtectionCertificate, certificateWarningDays);
await EnsureAuthRegistryDefaultsAsync(app.Services);
await EnsureAdminPermissionDefaultsAsync(app.Services);
app.UseForwardedHeaders(); app.UseForwardedHeaders();
if (!string.IsNullOrWhiteSpace(pathBase)) if (!string.IsNullOrWhiteSpace(pathBase))
@ -140,8 +306,11 @@ app.Use(async (context, next) =>
{ {
if (issuerUri is not null && IsOpenIddictRequest(context.Request.Path)) if (issuerUri is not null && IsOpenIddictRequest(context.Request.Path))
{ {
context.Request.Scheme = issuerUri.Scheme;
context.Request.Host = HostString.FromUriComponent(issuerUri); context.Request.Host = HostString.FromUriComponent(issuerUri);
if (context.Request.IsHttps || allowInternalHttpTokenEndpoint)
{
context.Request.Scheme = issuerUri.Scheme;
}
} }
await next(); await next();
@ -149,6 +318,44 @@ app.Use(async (context, next) =>
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseRateLimiter();
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.UseAuthorization();
app.MapControllers(); app.MapControllers();
@ -195,3 +402,40 @@ static bool IsOpenIddictRequest(PathString path)
|| path.StartsWithSegments("/oauth", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/oauth", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/auth", StringComparison.OrdinalIgnoreCase); || path.StartsWithSegments("/auth", StringComparison.OrdinalIgnoreCase);
} }
static RateLimitPartition<string> CreateFixedWindowLimiter(
HttpContext context,
string policyPrefix,
FixedWindowRateLimitSettings settings)
{
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 = settings.PermitLimit,
Window = TimeSpan.FromSeconds(settings.WindowSeconds),
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();
}
static async Task EnsureAdminPermissionDefaultsAsync(IServiceProvider services)
{
await using var scope = services.CreateAsyncScope();
var seeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
await seeder.EnsureDefaultsAsync();
}

View File

@ -13,7 +13,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "http://localhost:5050", "applicationUrl": "http://localhost:7850",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }

View File

@ -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);
}

View File

@ -0,0 +1,9 @@
using MemberCenter.Application.Models.Email;
namespace MemberCenter.Application.Abstractions;
public interface IAccountEmailTemplateService
{
EmailTemplate BuildVerificationEmail(string verifyUrl);
EmailTemplate BuildPasswordResetEmail(string resetUrl);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -0,0 +1,8 @@
using System.Security.Claims;
namespace MemberCenter.Application.Abstractions;
public interface IAdminPermissionChecker
{
Task<bool> HasPermissionAsync(ClaimsPrincipal user, string permissionKey, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,6 @@
namespace MemberCenter.Application.Abstractions;
public interface IAdminPermissionSeeder
{
Task EnsureDefaultsAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,6 @@
namespace MemberCenter.Application.Abstractions;
public interface IAuditLogWriter
{
Task WriteAsync(string actorType, Guid? actorId, string action, object payload);
}

View File

@ -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);
}

View File

@ -0,0 +1,6 @@
namespace MemberCenter.Application.Abstractions;
public interface IEmailSender
{
Task<int> SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null);
}

View File

@ -1,3 +1,4 @@
using MemberCenter.Application.Models.Profile;
using MemberCenter.Application.Models.Newsletter; using MemberCenter.Application.Models.Newsletter;
namespace MemberCenter.Application.Abstractions; namespace MemberCenter.Application.Abstractions;
@ -13,4 +14,6 @@ public interface INewsletterService
Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email); Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email);
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences); Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences);
Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId); Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId);
Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsForUserAsync(Guid userId);
Task<UserSubscriptionSummaryDto?> UnsubscribeForUserAsync(Guid userId, Guid subscriptionId);
} }

View File

@ -0,0 +1,15 @@
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);
Task<UserProfileImageDto?> GetProfileImageAsync(Guid userId);
Task SaveProfileImageAsync(Guid userId, byte[] content, string contentType, int width, int height);
}

View File

@ -5,5 +5,8 @@ namespace MemberCenter.Application.Abstractions;
public interface ISecuritySettingsService public interface ISecuritySettingsService
{ {
Task<SecuritySettingsDto> GetAsync(); Task<SecuritySettingsDto> GetAsync();
Task SaveAsync(SecuritySettingsDto settings); Task<TokenLifetimeSettingsDto> GetTokenLifetimeSettingsAsync();
Task<SmtpSettingsDto> GetSmtpSettingsAsync();
Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null);
Task<int> SendTestEmailAsync(string toEmail, Guid? actorUserId = null);
} }

View File

@ -0,0 +1,12 @@
using MemberCenter.Application.Models.Admin;
namespace MemberCenter.Application.Abstractions;
public interface ITenantManagerService
{
Task<IReadOnlyList<TenantManagerDto>> ListManagersAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlySet<Guid>> ListTenantIdsForUserAsync(Guid userId, CancellationToken cancellationToken = default);
Task<bool> CanManageTenantAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default);
Task<bool> AssignAsync(Guid tenantId, Guid userId, Guid? actorId, CancellationToken cancellationToken = default);
Task<bool> RemoveAsync(Guid tenantId, Guid userId, Guid? actorId, CancellationToken cancellationToken = default);
}

View File

@ -5,7 +5,9 @@ namespace MemberCenter.Application.Abstractions;
public interface ITenantService public interface ITenantService
{ {
Task<IReadOnlyList<TenantDto>> ListAsync(); Task<IReadOnlyList<TenantDto>> ListAsync();
Task<IReadOnlyList<TenantDto>> ListForUserAsync(Guid userId);
Task<TenantDto?> GetAsync(Guid id); Task<TenantDto?> GetAsync(Guid id);
Task<TenantDto?> GetForUserAsync(Guid id, Guid userId);
Task<TenantDto> CreateAsync(string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null); Task<TenantDto> CreateAsync(string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null);
Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null); Task<TenantDto?> UpdateAsync(Guid id, string name, List<string> domains, string status, Guid? sendEngineWebhookClientId = null);
Task<bool> SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId); Task<bool> SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId);

View File

@ -0,0 +1,11 @@
namespace MemberCenter.Application.Abstractions;
public interface IUserConsentService
{
Task RecordRegistrationConsentAsync(
Guid userId,
string registrationMethod,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,106 @@
namespace MemberCenter.Application.Constants;
public static class AdminPermissions
{
public const string AdminRole = "admin";
public const string SuperuserRole = "superuser";
public const string TenantManagerRole = "tenant_manager";
public const string Home = "admin.home";
public const string Accounts = "admin.accounts";
public const string AccountsIndex = "admin.accounts.index";
public const string AccountsSetAdmin = "admin.accounts.set_admin";
public const string AccountsSetDisabled = "admin.accounts.set_disabled";
public const string AccountsResetPassword = "admin.accounts.reset_password";
public const string Tenants = "admin.tenants";
public const string TenantsIndex = "admin.tenants.index";
public const string TenantsCreate = "admin.tenants.create";
public const string TenantsEdit = "admin.tenants.edit";
public const string TenantsDelete = "admin.tenants.delete";
public const string NewsletterLists = "admin.newsletter_lists";
public const string NewsletterListsIndex = "admin.newsletter_lists.index";
public const string NewsletterListsCreate = "admin.newsletter_lists.create";
public const string NewsletterListsEdit = "admin.newsletter_lists.edit";
public const string NewsletterListsDelete = "admin.newsletter_lists.delete";
public const string Subscriptions = "admin.subscriptions";
public const string SubscriptionsIndex = "admin.subscriptions.index";
public const string SubscriptionsExport = "admin.subscriptions.export";
public const string OAuthClients = "admin.oauth_clients";
public const string OAuthClientsIndex = "admin.oauth_clients.index";
public const string OAuthClientsCreate = "admin.oauth_clients.create";
public const string OAuthClientsEdit = "admin.oauth_clients.edit";
public const string OAuthClientsDelete = "admin.oauth_clients.delete";
public const string OAuthClientsRotateSecret = "admin.oauth_clients.rotate_secret";
public const string AuditLogs = "admin.audit_logs";
public const string AuditLogsIndex = "admin.audit_logs.index";
public const string Security = "admin.security";
public const string SecurityIndex = "admin.security.index";
public const string SecuritySave = "admin.security.save";
public const string SecurityTestEmail = "admin.security.test_email";
public const string Blacklist = "admin.blacklist";
public const string BlacklistIndex = "admin.blacklist.index";
public const string BlacklistCreate = "admin.blacklist.create";
public static readonly IReadOnlyList<AdminPermissionDefinition> All =
[
new(Home, "Overview", "Open the admin overview."),
new(Accounts, "Accounts", "Open account governance."),
new(AccountsIndex, "Accounts / View", "View account governance."),
new(AccountsSetAdmin, "Accounts / Grant admin", "Grant or remove admin role.", SuperuserOnly: true),
new(AccountsSetDisabled, "Accounts / Disable account", "Disable or enable member accounts."),
new(AccountsResetPassword, "Accounts / Reset password", "Reset member passwords."),
new(Tenants, "Tenants", "Open tenant management."),
new(TenantsIndex, "Tenants / View", "View tenants."),
new(TenantsCreate, "Tenants / Create", "Create tenants."),
new(TenantsEdit, "Tenants / Edit", "Edit tenants."),
new(TenantsDelete, "Tenants / Delete", "Delete tenants."),
new(NewsletterLists, "Newsletter lists", "Open newsletter list management."),
new(NewsletterListsIndex, "Newsletter lists / View", "View newsletter lists."),
new(NewsletterListsCreate, "Newsletter lists / Create", "Create newsletter lists."),
new(NewsletterListsEdit, "Newsletter lists / Edit", "Edit newsletter lists."),
new(NewsletterListsDelete, "Newsletter lists / Delete", "Delete newsletter lists."),
new(Subscriptions, "Subscriptions", "Open subscription management."),
new(SubscriptionsIndex, "Subscriptions / View", "View subscriptions."),
new(SubscriptionsExport, "Subscriptions / Export", "Export subscriptions."),
new(OAuthClients, "OAuth clients", "Open OAuth client management."),
new(OAuthClientsIndex, "OAuth clients / View", "View OAuth clients."),
new(OAuthClientsCreate, "OAuth clients / Create", "Create OAuth clients."),
new(OAuthClientsEdit, "OAuth clients / Edit", "Edit OAuth clients."),
new(OAuthClientsDelete, "OAuth clients / Delete", "Delete OAuth clients."),
new(OAuthClientsRotateSecret, "OAuth clients / Rotate secret", "Rotate OAuth client secrets."),
new(AuditLogs, "Audit logs", "Open audit logs."),
new(AuditLogsIndex, "Audit logs / View", "View audit logs."),
new(Security, "Security", "Open security settings."),
new(SecurityIndex, "Security / View", "View security settings."),
new(SecuritySave, "Security / Save", "Save security settings."),
new(SecurityTestEmail, "Security / Test email", "Send security settings test email."),
new(Blacklist, "Blacklist", "Open email blacklist."),
new(BlacklistIndex, "Blacklist / View", "View email blacklist."),
new(BlacklistCreate, "Blacklist / Create", "Add or update email blacklist entries.")
];
public static readonly IReadOnlySet<string> TenantManagerPermissions = new HashSet<string>(StringComparer.Ordinal)
{
Home,
Tenants,
TenantsIndex,
NewsletterLists,
NewsletterListsIndex,
Subscriptions,
SubscriptionsIndex
};
}
public sealed record AdminPermissionDefinition(
string Key,
string Name,
string Description,
bool SuperuserOnly = false);

View File

@ -0,0 +1,7 @@
namespace MemberCenter.Application.Constants;
public static class ConsentVersions
{
public const string TermsVersion = "2026-07-17";
public const string PrivacyVersion = "2026-07-17";
}

View File

@ -0,0 +1,13 @@
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";
public const string NewsletterOneClickToken = "newsletter-one-click-token";
public const string OAuthToken = "oauth-token";
public const string PublicAuthTokenConsumption = "public-auth-token-consumption";
}

View File

@ -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);

View File

@ -5,4 +5,5 @@ public sealed record AuditLogDto(
string ActorType, string ActorType,
Guid? ActorId, Guid? ActorId,
string Action, string Action,
string? PayloadJson,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt);

View File

@ -2,4 +2,16 @@ namespace MemberCenter.Application.Models.Admin;
public sealed record SecuritySettingsDto( public sealed record SecuritySettingsDto(
int AccessTokenMinutes, 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);

View 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);

View File

@ -0,0 +1,8 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record TenantManagerDto(
Guid TenantId,
Guid UserId,
string Email,
string DisplayName,
DateTimeOffset CreatedAt);

View File

@ -0,0 +1,5 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record TokenLifetimeSettingsDto(
int AccessTokenMinutes,
int RefreshTokenDays);

View File

@ -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);

View File

@ -0,0 +1,6 @@
namespace MemberCenter.Application.Models.Email;
public sealed record EmailTemplate(
string Subject,
string TextBody,
string HtmlBody);

View File

@ -0,0 +1,17 @@
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,
bool IsDefault,
string? AddressMetaJson);

View File

@ -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);

View File

@ -0,0 +1,18 @@
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,
bool IsDefault,
string? AddressMetaJson);

View File

@ -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);

View File

@ -0,0 +1,10 @@
namespace MemberCenter.Application.Models.Profile;
public sealed record UserProfileImageDto(
Guid UserId,
byte[] Content,
string ContentType,
int Width,
int Height,
long SizeBytes,
DateTimeOffset UpdatedAt);

View File

@ -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);

View File

@ -0,0 +1,14 @@
namespace MemberCenter.Domain.Entities;
public sealed class AdminPermission
{
public Guid Id { get; set; }
public string Key { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsEnabled { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public List<AdminRolePermission> RolePermissions { get; set; } = new();
}

View File

@ -0,0 +1,10 @@
namespace MemberCenter.Domain.Entities;
public sealed class AdminRolePermission
{
public Guid RoleId { get; set; }
public Guid PermissionId { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public AdminPermission? Permission { get; set; }
}

View File

@ -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;
}

View 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();
}

View 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; }
}

View 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;
}

View File

@ -9,4 +9,5 @@ public sealed class Tenant
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public List<NewsletterList> NewsletterLists { get; set; } = new(); public List<NewsletterList> NewsletterLists { get; set; } = new();
public List<TenantManager> Managers { get; set; } = new();
} }

View File

@ -0,0 +1,11 @@
namespace MemberCenter.Domain.Entities;
public sealed class TenantManager
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public Guid? CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public Tenant? Tenant { get; set; }
}

View File

@ -0,0 +1,24 @@
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 bool IsDefault { get; set; }
public JsonDocument? AddressMetaJson { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -0,0 +1,13 @@
namespace MemberCenter.Domain.Entities;
public sealed class UserConsent
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string TermsVersion { get; set; } = string.Empty;
public string PrivacyVersion { get; set; } = string.Empty;
public string RegistrationMethod { get; set; } = string.Empty;
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public DateTimeOffset AcceptedAt { get; set; } = DateTimeOffset.UtcNow;
}

View 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;
}

View File

@ -0,0 +1,12 @@
namespace MemberCenter.Domain.Entities;
public sealed class UserProfileImage
{
public Guid UserId { get; set; }
public byte[] Content { get; set; } = Array.Empty<byte>();
public string ContentType { get; set; } = "image/webp";
public int Width { get; set; }
public int Height { get; set; }
public long SizeBytes { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -0,0 +1,239 @@
using System.Security.Cryptography.X509Certificates;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Configuration;
public static class CertificateLoader
{
public const string OpenIddictSigningKey = "certificates:openiddict:signing";
public const string OpenIddictEncryptionKey = "certificates:openiddict:encryption";
public const string DataProtectionKey = "certificates:data-protection";
public static void LogExpirationWarning(
ILogger logger,
string name,
X509Certificate2? certificate,
int warningDays)
{
if (certificate is null)
{
return;
}
var remaining = certificate.NotAfter.ToUniversalTime() - DateTime.UtcNow;
if (remaining <= TimeSpan.FromDays(Math.Clamp(warningDays, 1, 180)))
{
logger.LogWarning(
"Certificate {CertificateName} ({Thumbprint}) expires at {ExpiresAtUtc} ({RemainingDays:F1} days remaining).",
name,
certificate.Thumbprint,
certificate.NotAfter.ToUniversalTime(),
remaining.TotalDays);
}
}
public static IReadOnlyList<X509Certificate2> LoadPreviousFromConfiguration(
IConfiguration configuration,
string sectionPath)
{
return configuration.GetSection($"{sectionPath}:Previous")
.GetChildren()
.Select(section => LoadFromConfiguration(configuration, section.Path, required: true)!)
.ToArray();
}
public static X509Certificate2? LoadFromConfiguration(
IConfiguration configuration,
string sectionPath,
bool required)
{
var path = configuration[$"{sectionPath}:Path"]?.Trim();
var password = configuration[$"{sectionPath}:Password"];
if (string.IsNullOrWhiteSpace(path))
{
if (required)
{
throw new InvalidOperationException($"{sectionPath}:Path is required outside Development.");
}
return null;
}
if (!File.Exists(path))
{
throw new InvalidOperationException($"Certificate file configured by {sectionPath}:Path does not exist: {path}");
}
X509Certificate2 certificate;
try
{
certificate = new X509Certificate2(path, password, X509KeyStorageFlags.EphemeralKeySet);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Certificate configured by {sectionPath} could not be loaded.", ex);
}
ValidateCertificate(certificate, $"configured by {sectionPath}");
return certificate;
}
public static async Task<X509Certificate2?> LoadFromConfigurationOrDatabaseAsync(
IConfiguration configuration,
string connectionString,
string sectionPath,
string systemFlagKey,
bool required,
CancellationToken cancellationToken = default)
{
var configured = LoadFromConfiguration(configuration, sectionPath, required: false);
if (configured is not null)
{
return configured;
}
var stored = await LoadFromDatabaseAsync(connectionString, systemFlagKey, cancellationToken);
if (stored is not null)
{
return stored;
}
if (required)
{
throw new InvalidOperationException(
$"{sectionPath}:Path or DB certificate '{systemFlagKey}' is required outside Development. Run installer init or migrate to provision certificates.");
}
return null;
}
public static async Task<X509Certificate2?> LoadFromDatabaseAsync(
string connectionString,
string systemFlagKey,
CancellationToken cancellationToken = default)
{
await using var db = CreateDbContext(connectionString);
var flag = await db.SystemFlags
.AsNoTracking()
.SingleOrDefaultAsync(item => item.Key == systemFlagKey, cancellationToken);
if (flag is null || string.IsNullOrWhiteSpace(flag.Value))
{
return null;
}
X509Certificate2 certificate;
try
{
certificate = new X509Certificate2(
Convert.FromBase64String(flag.Value),
password: (string?)null,
X509KeyStorageFlags.EphemeralKeySet);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Certificate stored in DB flag '{systemFlagKey}' could not be loaded.", ex);
}
ValidateCertificate(certificate, $"stored in DB flag '{systemFlagKey}'");
return certificate;
}
public static async Task EnsureDatabaseCertificatesAsync(
MemberCenterDbContext db,
CancellationToken cancellationToken = default)
{
await EnsureDatabaseCertificateAsync(
db,
OpenIddictSigningKey,
"membercenter-openiddict-signing",
cancellationToken);
await EnsureDatabaseCertificateAsync(
db,
OpenIddictEncryptionKey,
"membercenter-openiddict-encryption",
cancellationToken);
await EnsureDatabaseCertificateAsync(
db,
DataProtectionKey,
"membercenter-data-protection",
cancellationToken);
}
private static async Task EnsureDatabaseCertificateAsync(
MemberCenterDbContext db,
string key,
string subjectName,
CancellationToken cancellationToken)
{
var flag = await db.SystemFlags.SingleOrDefaultAsync(item => item.Key == key, cancellationToken);
if (flag is not null && !string.IsNullOrWhiteSpace(flag.Value))
{
return;
}
using var rsa = System.Security.Cryptography.RSA.Create(4096);
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest(
$"CN={subjectName}",
rsa,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
critical: false));
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
using var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddYears(5));
var value = Convert.ToBase64String(certificate.Export(X509ContentType.Pfx));
if (flag is null)
{
db.SystemFlags.Add(new SystemFlag
{
Id = Guid.NewGuid(),
Key = key,
Value = value,
UpdatedAt = DateTimeOffset.UtcNow
});
return;
}
flag.Value = value;
flag.UpdatedAt = DateTimeOffset.UtcNow;
}
private static MemberCenterDbContext CreateDbContext(string connectionString)
{
var options = new DbContextOptionsBuilder<MemberCenterDbContext>()
.UseNpgsql(connectionString)
.UseOpenIddict()
.Options;
return new MemberCenterDbContext(options);
}
private static void ValidateCertificate(X509Certificate2 certificate, string source)
{
var now = DateTime.UtcNow;
if (!certificate.HasPrivateKey)
{
certificate.Dispose();
throw new InvalidOperationException($"Certificate {source} must contain a private key.");
}
if (now < certificate.NotBefore.ToUniversalTime() || now >= certificate.NotAfter.ToUniversalTime())
{
certificate.Dispose();
throw new InvalidOperationException($"Certificate {source} is not currently valid.");
}
}
}

View File

@ -0,0 +1,43 @@
namespace MemberCenter.Infrastructure.Configuration;
public static class ReturnUrlValidator
{
public static bool IsAllowedExternal(Uri candidate, IEnumerable<string?> allowedValues, bool allowInsecureHttp) =>
allowedValues.Any(value => IsWithinAllowedPrefix(candidate, value, allowInsecureHttp));
public static bool IsWithinAllowedPrefix(Uri candidate, string? allowedValue, bool allowInsecureHttp)
{
if (string.IsNullOrWhiteSpace(allowedValue) ||
!Uri.TryCreate(allowedValue, UriKind.Absolute, out var allowed) ||
!IsHttpScheme(allowed.Scheme) ||
!string.IsNullOrEmpty(allowed.UserInfo) ||
!string.IsNullOrEmpty(allowed.Query) ||
!string.IsNullOrEmpty(allowed.Fragment))
{
return false;
}
if (!allowInsecureHttp && (candidate.Scheme != Uri.UriSchemeHttps || allowed.Scheme != Uri.UriSchemeHttps))
{
return false;
}
if (!IsHttpScheme(candidate.Scheme) ||
!string.IsNullOrEmpty(candidate.UserInfo) ||
!string.Equals(candidate.Scheme, allowed.Scheme, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(candidate.IdnHost, allowed.IdnHost, StringComparison.OrdinalIgnoreCase) ||
candidate.Port != allowed.Port)
{
return false;
}
var allowedPath = allowed.AbsolutePath.TrimEnd('/');
return string.IsNullOrEmpty(allowedPath) ||
string.Equals(candidate.AbsolutePath, allowedPath, StringComparison.Ordinal) ||
candidate.AbsolutePath.StartsWith($"{allowedPath}/", StringComparison.Ordinal);
}
private static bool IsHttpScheme(string scheme) =>
string.Equals(scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ||
string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
}

View File

@ -0,0 +1,129 @@
namespace MemberCenter.Infrastructure.Configuration;
public sealed class IdentitySecurityOptions
{
public PasswordPolicyOptions Password { get; set; } = new();
public LockoutPolicyOptions Lockout { get; set; } = new();
}
public sealed class PasswordPolicyOptions
{
public int RequiredLength { get; set; } = 8;
public bool RequireDigit { get; set; } = true;
public bool RequireLowercase { get; set; } = true;
public bool RequireUppercase { get; set; } = true;
public bool RequireNonAlphanumeric { get; set; } = false;
}
public sealed class LockoutPolicyOptions
{
public bool AllowedForNewUsers { get; set; } = true;
public int MaxFailedAccessAttempts { get; set; } = 5;
public int DefaultLockoutMinutes { get; set; } = 15;
}
public sealed class FixedWindowRateLimitSettings
{
public int PermitLimit { get; set; }
public int WindowSeconds { get; set; }
public FixedWindowRateLimitSettings()
{
}
public FixedWindowRateLimitSettings(int permitLimit, int windowSeconds)
{
PermitLimit = permitLimit;
WindowSeconds = windowSeconds;
}
}
public sealed class RateLimitSettings
{
public WebRateLimitSettings Web { get; set; } = new();
public ApiRateLimitSettings Api { get; set; } = new();
}
public sealed class WebRateLimitSettings
{
public FixedWindowRateLimitSettings AuthLogin { get; set; } = new(10, 300);
public FixedWindowRateLimitSettings AuthRegister { get; set; } = new(5, 900);
public FixedWindowRateLimitSettings AuthRecovery { get; set; } = new(5, 900);
public FixedWindowRateLimitSettings AuthTokenConsumption { get; set; } = new(10, 600);
}
public sealed class ApiRateLimitSettings
{
public FixedWindowRateLimitSettings AuthRegister { get; set; } = new(5, 900);
public FixedWindowRateLimitSettings AuthRecovery { get; set; } = new(5, 900);
public FixedWindowRateLimitSettings AuthTokenConsumption { get; set; } = new(10, 600);
public FixedWindowRateLimitSettings NewsletterSubscribe { get; set; } = new(20, 600);
public FixedWindowRateLimitSettings NewsletterUnsubscribeToken { get; set; } = new(10, 600);
public FixedWindowRateLimitSettings NewsletterOneClickToken { get; set; } = new(60, 60);
public FixedWindowRateLimitSettings OAuthToken { get; set; } = new(30, 60);
}
public sealed class NewsletterTokenOptions
{
public int ConfirmTokenLifetimeDays { get; set; } = 7;
public int UnsubscribeTokenLifetimeDays { get; set; } = 7;
public int OneClickTokenLifetimeDays { get; set; } = 7;
public int OneClickBatchSizeLimit { get; set; } = 1000;
}
public sealed class FileAccessTokenOptions
{
public int DefaultLifetimeSeconds { get; set; } = 300;
public int MinimumLifetimeSeconds { get; set; } = 30;
public int MaximumLifetimeSeconds { get; set; } = 900;
}
public static class SecurityRuntimeOptionsValidator
{
public static void Validate(IdentitySecurityOptions options)
{
if (options.Password.RequiredLength < 8)
{
throw new InvalidOperationException("IdentitySecurity:Password:RequiredLength must be at least 8.");
}
if (options.Lockout.MaxFailedAccessAttempts <= 0 || options.Lockout.DefaultLockoutMinutes <= 0)
{
throw new InvalidOperationException("IdentitySecurity lockout values must be greater than zero.");
}
}
public static void Validate(RateLimitSettings options)
{
var policies = new[]
{
options.Web.AuthLogin, options.Web.AuthRegister, options.Web.AuthRecovery, options.Web.AuthTokenConsumption,
options.Api.AuthRegister, options.Api.AuthRecovery, options.Api.AuthTokenConsumption,
options.Api.NewsletterSubscribe, options.Api.NewsletterUnsubscribeToken,
options.Api.NewsletterOneClickToken, options.Api.OAuthToken
};
if (policies.Any(policy => policy.PermitLimit <= 0 || policy.WindowSeconds <= 0))
{
throw new InvalidOperationException("All RateLimits permit and window values must be greater than zero.");
}
}
public static void Validate(NewsletterTokenOptions options)
{
if (options.ConfirmTokenLifetimeDays <= 0 || options.UnsubscribeTokenLifetimeDays <= 0 ||
options.OneClickTokenLifetimeDays <= 0 || options.OneClickBatchSizeLimit <= 0)
{
throw new InvalidOperationException("All NewsletterTokens values must be greater than zero.");
}
}
public static void Validate(FileAccessTokenOptions options)
{
if (options.MinimumLifetimeSeconds <= 0 ||
options.DefaultLifetimeSeconds < options.MinimumLifetimeSeconds ||
options.MaximumLifetimeSeconds < options.DefaultLifetimeSeconds)
{
throw new InvalidOperationException(
"FileAccessTokens must satisfy 0 < MinimumLifetimeSeconds <= DefaultLifetimeSeconds <= MaximumLifetimeSeconds.");
}
}
}

View File

@ -0,0 +1,59 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
namespace MemberCenter.Infrastructure.Configuration;
public static class TrustedForwardedHeaders
{
public static void Configure(ForwardedHeadersOptions options, IConfiguration configuration)
{
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
var trustForwardedHeaders = configuration.GetValue("ReverseProxy:TrustForwardedHeaders", false);
if (trustForwardedHeaders)
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost;
options.ForwardLimit = Math.Clamp(configuration.GetValue<int?>("ReverseProxy:ForwardLimit") ?? 1, 1, 5);
return;
}
foreach (var value in Split(configuration["ReverseProxy:KnownProxies"]))
{
if (!IPAddress.TryParse(value, out var address))
{
throw new InvalidOperationException($"ReverseProxy:KnownProxies contains an invalid IP address: {value}");
}
options.KnownProxies.Add(address);
}
foreach (var value in Split(configuration["ReverseProxy:KnownNetworks"]))
{
var parts = value.Split('/', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 ||
!IPAddress.TryParse(parts[0], out var address) ||
!int.TryParse(parts[1], out var prefixLength) ||
prefixLength < 1 ||
prefixLength > (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128))
{
throw new InvalidOperationException($"ReverseProxy:KnownNetworks contains an invalid CIDR: {value}");
}
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(address, prefixLength));
}
var hasTrustedProxy = options.KnownProxies.Count > 0 || options.KnownNetworks.Count > 0;
options.ForwardedHeaders = hasTrustedProxy
? ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
: ForwardedHeaders.None;
options.ForwardLimit = Math.Clamp(configuration.GetValue<int?>("ReverseProxy:ForwardLimit") ?? 1, 1, 5);
}
private static IEnumerable<string> Split(string? value) =>
(value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}

View File

@ -1,3 +1,4 @@
using MemberCenter.Domain.Entities;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace MemberCenter.Infrastructure.Identity; namespace MemberCenter.Infrastructure.Identity;
@ -8,4 +9,11 @@ public class ApplicationUser : IdentityUser<Guid>
public bool IsBlacklisted { get; set; } public bool IsBlacklisted { get; set; }
public DateTimeOffset? BlacklistedAt { get; set; } public DateTimeOffset? BlacklistedAt { get; set; }
public string? BlacklistedBy { 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();
} }

View File

@ -6,12 +6,18 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</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.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" /> <PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" />
</ItemGroup> </ItemGroup>

View File

@ -1,12 +1,13 @@
using MemberCenter.Domain.Entities; using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Persistence; namespace MemberCenter.Infrastructure.Persistence;
public class MemberCenterDbContext public class MemberCenterDbContext
: IdentityDbContext<ApplicationUser, ApplicationRole, Guid> : IdentityDbContext<ApplicationUser, ApplicationRole, Guid>, IDataProtectionKeyContext
{ {
public MemberCenterDbContext(DbContextOptions<MemberCenterDbContext> options) public MemberCenterDbContext(DbContextOptions<MemberCenterDbContext> options)
: base(options) : base(options)
@ -14,13 +15,25 @@ public class MemberCenterDbContext
} }
public DbSet<Tenant> Tenants => Set<Tenant>(); public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<TenantManager> TenantManagers => Set<TenantManager>();
public DbSet<NewsletterList> NewsletterLists => Set<NewsletterList>(); public DbSet<NewsletterList> NewsletterLists => Set<NewsletterList>();
public DbSet<NewsletterSubscription> NewsletterSubscriptions => Set<NewsletterSubscription>(); public DbSet<NewsletterSubscription> NewsletterSubscriptions => Set<NewsletterSubscription>();
public DbSet<UserProfile> UserProfiles => Set<UserProfile>();
public DbSet<UserProfileImage> UserProfileImages => Set<UserProfileImage>();
public DbSet<UserConsent> UserConsents => Set<UserConsent>();
public DbSet<UserAddress> UserAddresses => Set<UserAddress>();
public DbSet<EmailBlacklist> EmailBlacklist => Set<EmailBlacklist>(); public DbSet<EmailBlacklist> EmailBlacklist => Set<EmailBlacklist>();
public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>(); public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>();
public DbSet<UnsubscribeToken> UnsubscribeTokens => Set<UnsubscribeToken>(); public DbSet<UnsubscribeToken> UnsubscribeTokens => Set<UnsubscribeToken>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<SystemFlag> SystemFlags => Set<SystemFlag>(); 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<AdminPermission> AdminPermissions => Set<AdminPermission>();
public DbSet<AdminRolePermission> AdminRolePermissions => Set<AdminRolePermission>();
public DbSet<FileAccessDownloadToken> FileAccessDownloadTokens => Set<FileAccessDownloadToken>();
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@ -38,6 +51,26 @@ public class MemberCenterDbContext
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
}); });
builder.Entity<TenantManager>(entity =>
{
entity.ToTable("tenant_managers");
entity.HasKey(x => new { x.TenantId, x.UserId });
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
entity.HasIndex(x => x.UserId).HasDatabaseName("idx_tenant_managers_user_id");
entity.HasOne(x => x.Tenant)
.WithMany(x => x.Managers)
.HasForeignKey(x => x.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<NewsletterList>(entity => builder.Entity<NewsletterList>(entity =>
{ {
entity.ToTable("newsletter_lists"); entity.ToTable("newsletter_lists");
@ -76,6 +109,94 @@ public class MemberCenterDbContext
.OnDelete(DeleteBehavior.SetNull); .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<UserProfileImage>(entity =>
{
entity.ToTable("user_profile_images");
entity.HasKey(x => x.UserId);
entity.Property(x => x.Content).IsRequired().HasColumnType("bytea");
entity.Property(x => x.ContentType).IsRequired().HasMaxLength(100);
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
entity.HasOne<ApplicationUser>()
.WithOne()
.HasForeignKey<UserProfileImage>(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<UserConsent>(entity =>
{
entity.ToTable("user_consents");
entity.HasKey(x => x.Id);
entity.Property(x => x.TermsVersion).IsRequired().HasMaxLength(50);
entity.Property(x => x.PrivacyVersion).IsRequired().HasMaxLength(50);
entity.Property(x => x.RegistrationMethod).IsRequired().HasMaxLength(50);
entity.Property(x => x.IpAddress).HasMaxLength(100);
entity.Property(x => x.UserAgent).HasMaxLength(500);
entity.Property(x => x.AcceptedAt).HasDefaultValueSql("now()");
entity.HasIndex(x => x.UserId).HasDatabaseName("idx_user_consents_user_id");
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(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.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.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 => builder.Entity<EmailBlacklist>(entity =>
{ {
entity.ToTable("email_blacklist"); entity.ToTable("email_blacklist");
@ -136,6 +257,101 @@ public class MemberCenterDbContext
entity.HasIndex(x => x.Key).IsUnique(); 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<AdminPermission>(entity =>
{
entity.ToTable("admin_permissions");
entity.HasKey(x => x.Id);
entity.Property(x => x.Key).IsRequired().HasMaxLength(200);
entity.Property(x => x.Name).IsRequired().HasMaxLength(200);
entity.Property(x => x.Description).HasMaxLength(1000);
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.Key).IsUnique();
});
builder.Entity<AdminRolePermission>(entity =>
{
entity.ToTable("admin_role_permissions");
entity.HasKey(x => new { x.RoleId, x.PermissionId });
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
entity.HasOne<ApplicationRole>()
.WithMany()
.HasForeignKey(x => x.RoleId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Permission)
.WithMany(x => x.RolePermissions)
.HasForeignKey(x => x.PermissionId)
.OnDelete(DeleteBehavior.Cascade);
});
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 => builder.Entity<ApplicationUser>(entity =>
{ {
entity.ToTable("users"); entity.ToTable("users");

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}

View File

@ -0,0 +1,78 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAdminPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "admin_permissions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, 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()"),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_admin_permissions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "admin_role_permissions",
columns: table => new
{
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
PermissionId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_admin_role_permissions", x => new { x.RoleId, x.PermissionId });
table.ForeignKey(
name: "FK_admin_role_permissions_admin_permissions_PermissionId",
column: x => x.PermissionId,
principalTable: "admin_permissions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_admin_role_permissions_roles_RoleId",
column: x => x.RoleId,
principalTable: "roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_admin_permissions_Key",
table: "admin_permissions",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_admin_role_permissions_PermissionId",
table: "admin_role_permissions",
column: "PermissionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "admin_role_permissions");
migrationBuilder.DropTable(
name: "admin_permissions");
}
}
}

View File

@ -0,0 +1,43 @@
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
[DbContext(typeof(MemberCenterDbContext))]
[Migration("20260710090000_RemoveUserAddressUsage")]
public partial class RemoveUserAddressUsage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_user_addresses_user_id_usage",
table: "user_addresses");
migrationBuilder.DropColumn(
name: "Usage",
table: "user_addresses");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Usage",
table: "user_addresses",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "shipping");
migrationBuilder.CreateIndex(
name: "idx_user_addresses_user_id_usage",
table: "user_addresses",
columns: new[] { "UserId", "Usage" });
}
}
}

View File

@ -0,0 +1,46 @@
using System;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MemberCenterDbContext))]
[Migration("20260710100000_AddUserProfileImages")]
public partial class AddUserProfileImages : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "user_profile_images",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Content = table.Column<byte[]>(type: "bytea", nullable: false),
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Width = table.Column<int>(type: "integer", nullable: false),
Height = table.Column<int>(type: "integer", nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_user_profile_images", x => x.UserId);
table.ForeignKey(
name: "FK_user_profile_images_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "user_profile_images");
}
}
}

View File

@ -0,0 +1,65 @@
using System;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MemberCenterDbContext))]
[Migration("20260710110000_AddTenantManagers")]
public partial class AddTenantManagers : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "tenant_managers",
columns: table => new
{
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_tenant_managers", x => new { x.TenantId, x.UserId });
table.ForeignKey(
name: "FK_tenant_managers_tenants_TenantId",
column: x => x.TenantId,
principalTable: "tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_tenant_managers_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_tenant_managers_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_tenant_managers_CreatedBy",
table: "tenant_managers",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "idx_tenant_managers_user_id",
table: "tenant_managers",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "tenant_managers");
}
}
}

View File

@ -0,0 +1,52 @@
using System;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MemberCenterDbContext))]
[Migration("20260713090000_AddUserConsents")]
public partial class AddUserConsents : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "user_consents",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TermsVersion = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
PrivacyVersion = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
RegistrationMethod = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
IpAddress = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
UserAgent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
AcceptedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_user_consents", x => x.Id);
table.ForeignKey(
name: "FK_user_consents_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_user_consents_user_id",
table: "user_consents",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "user_consents");
}
}
}

Some files were not shown because too many files have changed in this diff Show More