Merge branch 'develop'
This commit is contained in:
commit
a2a43250df
61
.env.example
61
.env.example
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
14
README.md
14
README.md
@ -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 API(OAuth/OIDC、訂閱、管理 API)
|
│ ├── MemberCenter.Api/ # REST API(OAuth/OIDC、訂閱、管理 API)
|
||||||
│ ├── MemberCenter.Web/ # MVC Web UI(會員與後台頁面)
|
│ ├── MemberCenter.Web/ # MVC Web UI(client-first 會員介面 + Areas/Admin 管理介面)
|
||||||
|
│ ├── MemberCenter.TestSite/ # 手動整合測試站(API / redirect login happy path)
|
||||||
│ ├── MemberCenter.Installer/ # 安裝與初始化 CLI(migrate/init/admin)
|
│ ├── MemberCenter.Installer/ # 安裝與初始化 CLI(migrate/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` 事件。
|
||||||
|
|||||||
90
docs/ADMIN_AUTHORIZATION.md
Normal file
90
docs/ADMIN_AUTHORIZATION.md
Normal 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.
|
||||||
260
docs/ADMIN_CLIENT_SPLIT_PLAN.md
Normal file
260
docs/ADMIN_CLIENT_SPLIT_PLAN.md
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
# Admin / Client UI 拆分工作計劃
|
||||||
|
|
||||||
|
## 目標
|
||||||
|
|
||||||
|
- 在同一個 `MemberCenter.Web` 專案中,將會員端與管理端 UI 明確分區。
|
||||||
|
- 保持單一登入入口與單一帳號系統。
|
||||||
|
- 讓具有 `admin` 權限的帳號同時可使用會員功能與管理功能。
|
||||||
|
- 將目前偏向後台的共用介面,調整為 client-first 的會員中心體驗。
|
||||||
|
- 補齊會員註冊、Google 第三方登入與訂閱資料銜接流程。
|
||||||
|
|
||||||
|
## 已確認決策
|
||||||
|
|
||||||
|
### 帳號與登入
|
||||||
|
|
||||||
|
- `Admin` 與 `Client` 共用同一個登入入口。
|
||||||
|
- `Admin` 帳號同時也是會員帳號,可使用會員介面功能。
|
||||||
|
- 不拆成兩套認證系統,不建立獨立 admin login。
|
||||||
|
|
||||||
|
### UI 與導覽
|
||||||
|
|
||||||
|
- 會員端為主介面。
|
||||||
|
- 功能選單採分組方式呈現。
|
||||||
|
- 一般會員只看到會員功能。
|
||||||
|
- 具有 `admin` role 的帳號,額外看到 `Admin` 功能分類。
|
||||||
|
- `Admin` 分類展開後顯示管理功能連結。
|
||||||
|
- 目前先以 `admin` role 做整包顯示,不先做細權限切分。
|
||||||
|
|
||||||
|
### 未授權存取
|
||||||
|
|
||||||
|
- 非 admin 使用者存取 `/admin/*` 時,回應 `404`。
|
||||||
|
- 不使用 `403` 頁面暴露後台存在。
|
||||||
|
|
||||||
|
## 目標範圍
|
||||||
|
|
||||||
|
### 本次要做
|
||||||
|
|
||||||
|
- 調整 `MemberCenter.Web` 路由與結構,將管理端移入 `Areas/Admin`。
|
||||||
|
- 將現有共用 layout 改為 client-first 導覽。
|
||||||
|
- 將 admin 功能從全站共用導覽中抽離,改成 role-based 顯示。
|
||||||
|
- 建立 admin 路由未授權時回 `404` 的處理方式。
|
||||||
|
- 補上會員註冊、第三方登入與訂閱綁定的工作規劃。
|
||||||
|
|
||||||
|
### 本次不做
|
||||||
|
|
||||||
|
- 細粒度權限模型,例如依功能模組拆 `tenant.read`、`audit.read`。
|
||||||
|
- 獨立的 `AdminWeb` / `ClientWeb` 專案拆分。
|
||||||
|
- 大幅重做視覺設計。
|
||||||
|
- API 權限模型重構。
|
||||||
|
- 註冊確認信寄送實作。
|
||||||
|
|
||||||
|
## 實作策略
|
||||||
|
|
||||||
|
### 策略原則
|
||||||
|
|
||||||
|
- 先切 UI 邊界,再保留既有 Identity 與 role policy。
|
||||||
|
- 先做低風險結構重整,不同時引入細權限與大幅 UI redesign。
|
||||||
|
- 保持既有 URL 慣例,避免不必要的 route breakage。
|
||||||
|
|
||||||
|
### 預期結構
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/MemberCenter.Web/
|
||||||
|
├── Areas/
|
||||||
|
│ └── Admin/
|
||||||
|
│ ├── Controllers/
|
||||||
|
│ └── Views/
|
||||||
|
├── Controllers/ # client only
|
||||||
|
├── Views/
|
||||||
|
│ ├── Shared/
|
||||||
|
│ │ ├── _Layout.cshtml # client-first layout
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 分階段計劃
|
||||||
|
|
||||||
|
### Phase 0: 會員註冊與帳號銜接規格補齊
|
||||||
|
|
||||||
|
狀態:已完成
|
||||||
|
|
||||||
|
目標:先將會員建立、第三方登入與訂閱資料綁定的規則固定,避免後續 UI 與 auth 重構互相衝突。
|
||||||
|
|
||||||
|
#### 需求規則
|
||||||
|
|
||||||
|
- 會員帳號以 `email` 為主要識別。
|
||||||
|
- `UserName` 強制等於 `Email`。
|
||||||
|
- 不提供獨立 username。
|
||||||
|
- 本地註冊完成後,帳號標記為未認證。
|
||||||
|
- 本階段先不寄送確認信。
|
||||||
|
- 未認證帳號仍可登入。
|
||||||
|
- 後續功能完整後,未認證帳號將可被限制部分功能;本階段先保留此狀態與擴充空間。
|
||||||
|
- 支援 Google 作為第一個第三方登入/註冊 provider。
|
||||||
|
- Google 第一次登入時,若系統已存在相同 email 的本地帳號,直接 auto-link。
|
||||||
|
- Google 回傳 email 即使未驗證,仍允許建立帳號或連接既有帳號。
|
||||||
|
- 使用者若先以本地帳號註冊,之後再以 Google 同 email 登入,應連接到同一個帳號,不建立第二個 user。
|
||||||
|
- 註冊成功後,若系統中已有相同 email 的訂閱資料,需將相關 `newsletter_subscriptions.user_id` 補上。
|
||||||
|
- 訂閱綁定時必須保留既有訂閱狀態與偏好,不可覆蓋。
|
||||||
|
- 訂閱綁定完成後需補一筆 audit log。
|
||||||
|
- 後續若導入事件,保留發送 `subscription.linked_to_user` 的擴充空間。
|
||||||
|
|
||||||
|
#### 子工作
|
||||||
|
|
||||||
|
- 已完成:定義本地註冊後的帳號狀態與登入規則。
|
||||||
|
- 已完成:定義 Google external login / register / auto-link 流程。
|
||||||
|
- 已完成:定義訂閱資料綁定與 audit log 寫入時機。
|
||||||
|
- 已完成:將上述規則同步反映到目前的 Web / API 實作階段。
|
||||||
|
- 已完成:`subscription.linked_to_user` 事件發送。
|
||||||
|
- 註記:未認證帳號的功能限制屬後續能力擴充,不阻擋本 phase 完成。
|
||||||
|
- 註記:Google 實際整合驗證仍需提供 Google OAuth 設定,屬外部驗證條件,不阻擋本 phase 完成。
|
||||||
|
|
||||||
|
完成條件:
|
||||||
|
|
||||||
|
- 註冊、Google 登入、同 email 帳號連接、訂閱綁定規則均有明確定義。
|
||||||
|
- 後續 Phase 1 之後的 UI 與 auth 重構可直接依規則實作。
|
||||||
|
|
||||||
|
### Phase 1: Route 與目錄切分
|
||||||
|
|
||||||
|
狀態:已完成
|
||||||
|
|
||||||
|
目標:先建立清楚的 UI 邊界。
|
||||||
|
|
||||||
|
- 將現有 `Controllers/Admin/*` 移入 `Areas/Admin/Controllers/*`。
|
||||||
|
- 將現有 `Views/Admin/*` 移入 `Areas/Admin/Views/*`。
|
||||||
|
- 調整 route 設定,讓 `/admin/*` 由 area route 處理。
|
||||||
|
- 確認既有 admin URL 可維持不變。
|
||||||
|
|
||||||
|
完成條件:
|
||||||
|
|
||||||
|
- 所有 admin 頁面由 `Areas/Admin` 提供。
|
||||||
|
- 會員端 controller 不再與 admin controller 混在同一層。
|
||||||
|
|
||||||
|
### Phase 2: Layout 與導覽切分
|
||||||
|
|
||||||
|
狀態:已完成
|
||||||
|
|
||||||
|
目標:把 UI 改成 client-first,不再全站露出後台功能。
|
||||||
|
|
||||||
|
- 重構共用 layout,移除固定顯示的 admin 連結。
|
||||||
|
- 建立 client-first 功能選單。
|
||||||
|
- 若使用者具 `admin` role,顯示 `Admin` 功能分類。
|
||||||
|
- `Admin` 分類底下先列出既有管理功能:
|
||||||
|
- Tenants
|
||||||
|
- Newsletter Lists
|
||||||
|
- Subscriptions
|
||||||
|
- OAuth Clients
|
||||||
|
- Audit Logs
|
||||||
|
- Security
|
||||||
|
- Blacklist
|
||||||
|
|
||||||
|
完成條件:
|
||||||
|
|
||||||
|
- 一般會員不會在主選單看到 admin 連結。
|
||||||
|
- admin 使用者可從同一套主介面展開進入管理功能。
|
||||||
|
|
||||||
|
### Phase 3: Admin 畫面容器整理
|
||||||
|
|
||||||
|
狀態:已完成
|
||||||
|
|
||||||
|
目標:讓進入 admin 區後有明確上下文。
|
||||||
|
|
||||||
|
- 規劃 admin area 是否使用獨立 layout。
|
||||||
|
- 若使用獨立 layout,保留回會員區入口。
|
||||||
|
- 若先共用 layout,至少在 admin 頁面標示目前位於管理區。
|
||||||
|
|
||||||
|
建議:
|
||||||
|
|
||||||
|
- 第一版可先採用共用主殼 + admin 區塊標示。
|
||||||
|
- 若後續 admin 功能持續增長,再抽 `_AdminLayout`。
|
||||||
|
|
||||||
|
目前進度:
|
||||||
|
|
||||||
|
- 已完成:獨立 `Admin` area layout。
|
||||||
|
- 已完成:保留回會員區入口。
|
||||||
|
- 已完成:admin shell 基礎結構整理(top bar、side nav、active state、區域標示)。
|
||||||
|
- 已完成:補上可替換的基礎樣式 hooks,避免後續設計重做時需要拆 route 或 view 結構。
|
||||||
|
- 註記:後續若需進一步整理 admin/client 的整體體驗、內容層級、表格與表單版型,視為 UI/UX refinement,不阻擋本 phase 完成。
|
||||||
|
|
||||||
|
完成條件:
|
||||||
|
|
||||||
|
- 使用者進入 admin 頁面時,有清楚的區域辨識。
|
||||||
|
|
||||||
|
### Phase 4: 未授權存取改為 404
|
||||||
|
|
||||||
|
狀態:已完成
|
||||||
|
|
||||||
|
目標:保留授權檢查,同時隱藏 admin surface。
|
||||||
|
|
||||||
|
- 保留 `[Authorize(Policy = "Admin")]`。
|
||||||
|
- 增加 admin 未授權時的統一處理,避免顯示預設 `403`。
|
||||||
|
- 確認未登入與已登入但非 admin 的行為符合預期。
|
||||||
|
|
||||||
|
已定案:
|
||||||
|
|
||||||
|
- 未登入進 `/admin/*`:直接回 `404`
|
||||||
|
- 已登入但非 admin 進 `/admin/*`:直接回 `404`
|
||||||
|
|
||||||
|
目前進度:
|
||||||
|
|
||||||
|
- 已完成:保留 `[Authorize(Policy = "Admin")]`。
|
||||||
|
- 已完成:admin 未授權時不顯示預設 `403`,改為 `404`。
|
||||||
|
- 已完成:目前實作上,未登入與非 admin 存取 `/admin/*` 均回 `404`。
|
||||||
|
- 已完成:將「未登入也回 `404`」正式定案並同步到工作計劃。
|
||||||
|
|
||||||
|
### Phase 5: 驗證與文件更新
|
||||||
|
|
||||||
|
狀態:已完成
|
||||||
|
|
||||||
|
目標:確保重構後行為可驗證、文件一致。
|
||||||
|
|
||||||
|
- 驗證會員端主要頁面仍可正常使用。
|
||||||
|
- 驗證 admin 帳號可以:
|
||||||
|
- 使用會員功能
|
||||||
|
- 看見 `Admin` 選單
|
||||||
|
- 進入 admin 各頁
|
||||||
|
- 驗證非 admin 帳號無法看見 admin 選單,且直接進 admin URL 會得到 `404`
|
||||||
|
- 更新 README / UI 文件中的 web 結構描述
|
||||||
|
|
||||||
|
目前進度:
|
||||||
|
|
||||||
|
- 已完成:`dotnet build MemberCenter.sln -m:1`
|
||||||
|
- 已完成:會員端主要頁面可用性驗證(首頁 / login / register)。
|
||||||
|
- 已完成:admin 帳號操作驗證(可登入、可進 member profile、可進 admin route)。
|
||||||
|
- 已完成:一般會員登入驗證(可登入、可進 profile、首頁不顯示 admin 群組)。
|
||||||
|
- 已完成:非 admin / 未登入情境驗證(匿名打 `/admin/*` 回 `404`)。
|
||||||
|
- 已完成:計劃文件、README、UI / Flow / Use Case / Design / OpenAPI 相關文件更新。
|
||||||
|
- 註記:Google 真實 round-trip 驗證需提供 Google OAuth 設定,屬外部條件,不阻擋本 phase 完成。
|
||||||
|
|
||||||
|
## 影響檔案預估
|
||||||
|
|
||||||
|
- `src/MemberCenter.Web/Program.cs`
|
||||||
|
- `src/MemberCenter.Web/Views/Shared/_Layout.cshtml`
|
||||||
|
- `src/MemberCenter.Web/Controllers/Admin/*`
|
||||||
|
- `src/MemberCenter.Web/Views/Admin/*`
|
||||||
|
- 新增 `src/MemberCenter.Web/Areas/Admin/...`
|
||||||
|
- 視需要更新 `docs/UI.md`
|
||||||
|
|
||||||
|
## 風險與注意事項
|
||||||
|
|
||||||
|
- Area 導入後,view 路徑與 route mapping 容易有小錯誤,需要逐頁驗證。
|
||||||
|
- 若直接共用同一個 layout,需避免 client 與 admin 的語意混亂。
|
||||||
|
- `404` 偽裝策略要搭配真正的 authorization,不能只靠 route 隱藏。
|
||||||
|
- 若未登入也直接回 `404`,可能會讓合法 admin 使用者失去登入引導;這點需明確決策。
|
||||||
|
|
||||||
|
## 建議執行順序
|
||||||
|
|
||||||
|
1. 先完成 Phase 0,確認註冊、Google 登入與訂閱綁定規則。
|
||||||
|
2. 再完成 Phase 1,做純結構重整。
|
||||||
|
3. 接著做 Phase 2,修正選單與角色顯示。
|
||||||
|
4. 然後決定 Phase 3 要共用 layout 還是抽 admin layout。
|
||||||
|
5. 再做 Phase 4,補齊 `404` 授權行為。
|
||||||
|
6. 最後做 Phase 5 的驗證與文件更新。
|
||||||
|
|
||||||
|
## 本次文件用途
|
||||||
|
|
||||||
|
這份計劃作為後續逐步實作的工作底稿。後續每一步都應以「單一階段可驗證完成」為原則,避免一次改太多導致 routing、授權與 UI 問題混在一起。
|
||||||
|
|
||||||
|
## 目前總結
|
||||||
|
|
||||||
|
- 已完成:Phase 0、Phase 1、Phase 2、Phase 3、Phase 4、Phase 5
|
||||||
|
- 部分完成:Phase 5
|
||||||
135
docs/CONFIGURATION.md
Normal file
135
docs/CONFIGURATION.md
Normal 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 filtering;Production 建議填實際 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 值皆須大於 0;File 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 endpoint;issuer 仍為 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 registry;audience 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;有效範圍 1–180。 |
|
||||||
|
|
||||||
|
## 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;限制 1–5。 |
|
||||||
|
|
||||||
|
`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 headers;allowlist 都為空時完全忽略 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 分鐘;5–1440。 |
|
||||||
|
| Refresh token lifetime | 30 天;1–365。 |
|
||||||
|
| 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` 為準。
|
||||||
204
docs/DESIGN.md
204
docs/DESIGN.md
@ -12,11 +12,13 @@
|
|||||||
## 2. 架構原則
|
## 2. 架構原則
|
||||||
- OAuth2 + OIDC(Authorization Code + PKCE)
|
- OAuth2 + OIDC(Authorization 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` 查詢或操作
|
||||||
- 訂閱狀態同步目前採 webhook(event payload);queue 為後續可選擴充
|
- 訂閱狀態同步採 webhook(event payload);queue 可作為未來擴充選項
|
||||||
- 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 不同 origin,Web 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-closed:token 缺少、格式錯誤或不相符的 `tenant_id` 時拒絕;跨 tenant 操作必須使用獨立 global scope
|
||||||
|
|
||||||
### 租戶端取 Token(Client Credentials)
|
### 租戶端取 Token(Client 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 驗簽 JWT(JWS)
|
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS)
|
||||||
- 驗簽通過後將 `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 限 `5–1440` 分鐘,refresh 限 `1–365` 天
|
||||||
- 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 ring;key ring 與密文同存 DB 只能避免設定值直接裸露,不能抵抗完整 DB 外洩
|
||||||
|
- Production API 必須以設定注入的 PFX 作為 OpenIddict signing / encryption certificates;API 與 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-Policy;Razor 不保留 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. 其他文件
|
||||||
|
|||||||
@ -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 或未註冊 client;refresh token 統一送至 `/oauth/token`
|
||||||
|
- [Auth] 新簽發 token 套用後台安全設定:access token `5–1440` 分鐘、refresh token `1–365` 天
|
||||||
|
|
||||||
|
## 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 Engine(Client Credentials + JWT 驗簽)
|
## F-02b 內容站台呼叫 Send Engine(Client 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-closed;Bearer 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`
|
||||||
|
|
||||||
註記:此流程目前尚未在程式中落地(屬待辦)。
|
|
||||||
|
|||||||
107
docs/INSTALL.md
107
docs/INSTALL.md
@ -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 與 HSTS;Member 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` 不是 HTTPS,API 會拒絕啟動;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 數,允許範圍 `1–5`。
|
||||||
|
- 不使用 `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 PFX:OpenIddict 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`(允許 `1–180`);進入期限後 API / Web 啟動會寫入 warning log,Production 應以 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) 建立 roles(admin, support)
|
3) 建立 roles(superuser, 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
803
docs/MEMBER_UPGRADE_PLAN.md
Normal 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:
|
||||||
|
- Web:login / register / forgot password / resend verification
|
||||||
|
- API:register / forgot password / resend verification
|
||||||
|
- Newsletter API:public 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 token,File Access agent 回打 Member Center 驗證
|
||||||
|
- Auth resource registry 第一版已落地:
|
||||||
|
- 新增 `auth_resources`、`auth_resource_scopes`、`auth_client_usage_permissions`
|
||||||
|
- `TokenController` 已改由 scope 查 registry 決定 token audiences
|
||||||
|
- OAuth client usage 可使用的 scopes 已改由 DB permission matrix 決定
|
||||||
|
- 預設資源包含 `member_center_api`、`send_engine_api`、`file_access_api`
|
||||||
|
|
||||||
|
### 部分實作
|
||||||
|
- `SendEngine__BaseUrl`、`SendEngine__WebhookSecret` 仍停留在設定來源與 options binding,尚未進入可編輯的管理畫面。
|
||||||
|
- Auth 資源 / audience / scope registry 已完成 DB 驅動第一版,仍缺管理 UI。
|
||||||
|
- 帳號治理後台、角色模型與 disabled account 規則已完成第一版,但仍缺進一步細化與保護規則。
|
||||||
|
- profile / addresses / subscriptions 畫面與驗證目前為最小可用版本,尚未完成 UI refinement。
|
||||||
|
|
||||||
|
### 待補項
|
||||||
|
- File Access 的 OAuth client usage、scope、audience 已落地;delegated download token issuing / validation 已落在 Member Center。
|
||||||
|
- Token resource / audience 已抽象為 registry;後續需補 resource registry 管理畫面。
|
||||||
|
- Email 樣板正式文案與會員 / 後台 UI 細節仍待整理。
|
||||||
|
- 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 Center;File Access agent 尚需串接 validation endpoint
|
||||||
|
|
||||||
|
### 9. 審計紀錄
|
||||||
|
狀態:`大致完成,少數治理事件待續作`
|
||||||
|
- 下列事件必須寫入 audit log:
|
||||||
|
- 註冊
|
||||||
|
- Email 驗證成功
|
||||||
|
- 忘記密碼申請
|
||||||
|
- 密碼重設成功
|
||||||
|
- 已登入修改密碼
|
||||||
|
- Google external login 綁定
|
||||||
|
- 角色變更
|
||||||
|
- 帳號停用 / 啟用
|
||||||
|
- profile 修改
|
||||||
|
- 地址簿新增 / 編輯 / 刪除 / 預設地址切換
|
||||||
|
- 會員端直接退訂
|
||||||
|
- 系統設定修改
|
||||||
|
- OAuth client 建立
|
||||||
|
- OAuth client secret 旋轉
|
||||||
|
- OAuth client secret 顯示一次、旋轉與治理能力視為既有基線;細節之後再檢討。
|
||||||
|
|
||||||
|
目前進度:
|
||||||
|
- 帳號寄信、驗證、重設密碼、修改密碼、註冊、external login 綁定、角色變更、帳號停用 / 啟用、profile、地址、會員端退訂、系統設定修改均已有實作
|
||||||
|
- 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 / CloudFront;API / 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 已有 lockout;service API token flow 與更細觀測仍待續作
|
||||||
|
|
||||||
|
### 11. MFA 與非本期項目
|
||||||
|
狀態:`待續作`
|
||||||
|
- 在 Email 寄送能力完成後,可規劃 Email-based MFA / challenge 驗證。
|
||||||
|
- 其餘先明確列為未來可補項:
|
||||||
|
- consent / terms acceptance
|
||||||
|
- 資料匯出 / 刪除
|
||||||
|
- login history / device management
|
||||||
|
- 通知偏好中心
|
||||||
|
|
||||||
|
## 一次到位的實作範圍
|
||||||
|
1. 設定中心:
|
||||||
|
- SMTP
|
||||||
|
- Send Engine
|
||||||
|
- Auth 資源設定
|
||||||
|
- token lifetime
|
||||||
|
2. Identity 與通知:
|
||||||
|
- Email 驗證寄信
|
||||||
|
- 忘記密碼寄信
|
||||||
|
- 重送驗證信
|
||||||
|
3. 會員主資料:
|
||||||
|
- 基本資料
|
||||||
|
- 地址簿
|
||||||
|
- Profile UI / API
|
||||||
|
4. 訂閱管理:
|
||||||
|
- 我的訂閱頁
|
||||||
|
- current-user 型訂閱 API
|
||||||
|
- 直接退訂流程
|
||||||
|
5. 權限與角色:
|
||||||
|
- `superuser`
|
||||||
|
- `admin`
|
||||||
|
- `member` + verified / unverified
|
||||||
|
- 帳號管理與角色指派
|
||||||
|
6. Auth 中心:
|
||||||
|
- resource registry
|
||||||
|
- profile scopes
|
||||||
|
- file access scopes
|
||||||
|
- delegated token
|
||||||
|
7. 安全治理:
|
||||||
|
- audit log
|
||||||
|
- rate limit
|
||||||
|
- MFA 預留
|
||||||
|
8. 文件、測試與 installer 同步調整
|
||||||
|
|
||||||
|
目前整體狀態:
|
||||||
|
- `1`:部分完成
|
||||||
|
- `2`:核心完成,文案待續作
|
||||||
|
- `3`:核心完成
|
||||||
|
- `4`:核心完成,但本輪不再補強 newsletter refinement
|
||||||
|
- `5`:第一版完成
|
||||||
|
- `6`:先跳過 audience / scope 抽象化
|
||||||
|
- `7`:大致完成,仍有少量補強
|
||||||
|
- `8`:部分完成
|
||||||
|
|
||||||
|
## 影響範圍
|
||||||
|
|
||||||
|
### Web
|
||||||
|
- 新增設定畫面與表單。
|
||||||
|
- 新增帳號管理畫面。
|
||||||
|
- 調整註冊成功後導引與驗證提醒文案。
|
||||||
|
- 新增會員 profile 編輯頁、地址簿頁與我的訂閱頁。
|
||||||
|
|
||||||
|
### API
|
||||||
|
- 若設定畫面走 API,需新增設定讀寫端點。
|
||||||
|
- 若帳號管理後台走 API,需新增角色管理與帳號查詢端點。
|
||||||
|
- File Access 的 resource / audience mapping、file scopes、delegated token issuing / validation API 已完成;File Access agent 串接與流程測試仍待補。
|
||||||
|
- 需新增 current-user 型 profile / addresses / subscriptions API 與對應 scopes。
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- 新增 SMTP sender 與設定存取服務。
|
||||||
|
- 調整帳號 provisioning 與角色管理服務。
|
||||||
|
- token resource resolver 已抽到 registry service;File Access delegated token issuer / validator 已落在 Member Center。
|
||||||
|
- 新增會員基本資料與地址簿的資料模型與服務層。
|
||||||
|
|
||||||
|
### Installer
|
||||||
|
- 建立或提升 `superuser`。
|
||||||
|
- 視命名策略決定是否保留 `add-admin`,或改名為更貼近語意的指令。
|
||||||
|
|
||||||
|
## 文件與實作同步原則
|
||||||
|
- 文件中的「已存在」代表已有 controller / route / view / 基礎邏輯,不代表整體功能已完成產品化。
|
||||||
|
- 本規劃完成後,應同步回寫:
|
||||||
|
- `docs/UI.md`
|
||||||
|
- `docs/FLOWS.md`
|
||||||
|
- `docs/INSTALL.md`
|
||||||
|
- `README.md`
|
||||||
@ -81,7 +81,7 @@ curl -s -X POST https://{send-engine}/api/send-jobs \
|
|||||||
- Send Engine 以 Member Center 的 JWKS 驗簽 access token(JWS)。
|
- Send Engine 以 Member Center 的 JWKS 驗簽 access token(JWS)。
|
||||||
- 驗證重點:`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 來源
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
112
docs/OPENAPI.md
112
docs/OPENAPI.md
@ -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:訂閱/確認/退訂/偏好
|
||||||
- Admin:Tenants/Lists/OAuth Clients(MVP CRUD)
|
- Admin:Tenants/Lists/OAuth Clients(MVP CRUD)
|
||||||
@ -19,14 +21,21 @@
|
|||||||
- Bearer JWT(API 使用)
|
- Bearer JWT(API 使用)
|
||||||
|
|
||||||
## 補充說明
|
## 補充說明
|
||||||
- `/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 `5–1440` 分鐘、refresh token `1–365` 天;設定儲存後下一次簽發立即生效
|
||||||
- Access token 以 JWT(JWS)簽發,建議驗證 `iss` 與 `aud`
|
- Access token 以 JWT(JWS)簽發,建議驗證 `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-scoped,SES 回寫用)
|
- `newsletter:events.write.global`(platform-scoped,SES 回寫用)
|
||||||
|
- `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 驗簽 JWT(JWS),並驗證 `scope/tenant_id/exp`
|
- Send Engine 建議以 JWKS 驗簽 JWT(JWS),並驗證 `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 Auth(A service / client / access agent)
|
||||||
|
|
||||||
|
用途:
|
||||||
|
- `A service` 上傳檔案到 file space
|
||||||
|
- `A service` 對 client 發放短效下載 URL
|
||||||
|
- access agent 依 token 決定是否放行上傳或下載
|
||||||
|
|
||||||
|
規則:
|
||||||
|
- Upload 採 S2S:
|
||||||
|
- `A service` 使用 `client_credentials`
|
||||||
|
- token 至少需帶 `files:upload.write`
|
||||||
|
- token 必須帶有效 `tenant_id`;缺少、格式錯誤或與 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 與 validation;File 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
113
docs/SCOPES.md
Normal 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 endpoint(register、forgot/reset、email verify)亦不屬於 scope 授權模型。
|
||||||
71
docs/TEST_SITE.md
Normal file
71
docs/TEST_SITE.md
Normal 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 不同 origin,Member Center 需設定:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Auth__WebLoginUrl=<Member Center Web login URL>
|
||||||
|
Auth__AllowedLoginReturnUrlPrefixes=<Member Center API issuer/base URL>
|
||||||
|
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
|
||||||
|
Auth__CookieDomain=<shared cookie domain, production subdomain SSO only>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 第一批 Happy Path
|
||||||
|
|
||||||
|
測試站目前包含:
|
||||||
|
|
||||||
|
1. Redirect login 拿 token
|
||||||
|
2. Redirect logout 清除 Member Center Web session 並回到 TestSite
|
||||||
|
3. `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,不取代完整自動化測試。
|
||||||
35
docs/UI.md
35
docs/UI.md
@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
## 會員中心(統一 UI)
|
## 會員中心(統一 UI)
|
||||||
### 會員端
|
### 會員端
|
||||||
- 註冊 / 登入 / 忘記密碼
|
- 註冊 / 登入 / 忘記密碼 / 修改密碼
|
||||||
- Email 驗證
|
- Email 驗證
|
||||||
- 個人資料(Email 為主)
|
- 個人資料(基本資料、聯絡方式、公司資訊)
|
||||||
- 訂閱管理(清單與偏好)
|
- 收貨地址簿
|
||||||
|
- 訂閱管理(清單與退訂)
|
||||||
- 退訂(單一清單)
|
- 退訂(單一清單)
|
||||||
- 連結外站(可選:回到來源站點)
|
- 連結外站(可選:回到來源站點)
|
||||||
|
|
||||||
@ -16,13 +17,14 @@
|
|||||||
- 訂閱查詢 / 匯出
|
- 訂閱查詢 / 匯出
|
||||||
- 審計紀錄查詢
|
- 審計紀錄查詢
|
||||||
- 系統設定(安全策略、token 時效)
|
- 系統設定(安全策略、token 時效)
|
||||||
|
- Auth 資源設定(resource / audience / scope / usage mapping)
|
||||||
|
|
||||||
## 各站自建 UI(API)
|
## 各站自建 UI(API)
|
||||||
### 會員端
|
### 會員端
|
||||||
- 登入 / 註冊 / 忘記密碼
|
- 登入 / 註冊 / 忘記密碼 / 修改密碼
|
||||||
- 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 整合設定頁
|
||||||
|
|||||||
@ -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` 事件)
|
||||||
|
|||||||
@ -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] }
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
18
src/MemberCenter.Api/Contracts/FileAccessRequests.cs
Normal file
18
src/MemberCenter.Api/Contracts/FileAccessRequests.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace MemberCenter.Api.Contracts;
|
||||||
|
|
||||||
|
public sealed record IssueFileDownloadTokenRequest(
|
||||||
|
[property: JsonPropertyName("tenant_id")] Guid TenantId,
|
||||||
|
[property: JsonPropertyName("user_id")] Guid UserId,
|
||||||
|
[property: JsonPropertyName("file_id")] string? FileId,
|
||||||
|
[property: JsonPropertyName("object_key")] string? ObjectKey,
|
||||||
|
[property: JsonPropertyName("method")] string? Method,
|
||||||
|
[property: JsonPropertyName("expires_in_seconds")] int? ExpiresInSeconds);
|
||||||
|
|
||||||
|
public sealed record ValidateFileDownloadTokenRequest(
|
||||||
|
[property: JsonPropertyName("token")] string Token,
|
||||||
|
[property: JsonPropertyName("tenant_id")] Guid TenantId,
|
||||||
|
[property: JsonPropertyName("file_id")] string? FileId,
|
||||||
|
[property: JsonPropertyName("object_key")] string? ObjectKey,
|
||||||
|
[property: JsonPropertyName("method")] string? Method);
|
||||||
33
src/MemberCenter.Api/Contracts/ProfileRequests.cs
Normal file
33
src/MemberCenter.Api/Contracts/ProfileRequests.cs
Normal 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);
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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}";
|
||||||
}
|
}
|
||||||
|
|||||||
208
src/MemberCenter.Api/Controllers/FileAccessController.cs
Normal file
208
src/MemberCenter.Api/Controllers/FileAccessController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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)}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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))
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<string>> ResolveResourcesAsync(IEnumerable<string> scopes)
|
||||||
{
|
{
|
||||||
resources.Add(_memberCenterAudience);
|
return await _authResourceRegistry.ResolveAudiencesAsync(scopes, HttpContext.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resources.Count == 0)
|
private async Task ApplyTokenLifetimesAsync(ClaimsPrincipal principal)
|
||||||
{
|
{
|
||||||
resources.Add(_memberCenterAudience);
|
var settings = await _securitySettingsService.GetTokenLifetimeSettingsAsync();
|
||||||
}
|
var accessMinutes = Math.Clamp(settings.AccessTokenMinutes, 5, 1440);
|
||||||
|
var refreshDays = Math.Clamp(settings.RefreshTokenDays, 1, 365);
|
||||||
return resources;
|
principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(accessMinutes));
|
||||||
|
principal.SetRefreshTokenLifetime(TimeSpan.FromDays(refreshDays));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IAccountEmailService
|
||||||
|
{
|
||||||
|
Task SendVerificationEmailAsync(Guid userId, string? fallbackBaseUrl = null);
|
||||||
|
Task SendPasswordResetEmailAsync(Guid userId, string? fallbackBaseUrl = null);
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using MemberCenter.Application.Models.Email;
|
||||||
|
|
||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IAccountEmailTemplateService
|
||||||
|
{
|
||||||
|
EmailTemplate BuildVerificationEmail(string verifyUrl);
|
||||||
|
EmailTemplate BuildPasswordResetEmail(string resetUrl);
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
using MemberCenter.Application.Models.Admin;
|
||||||
|
|
||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IAccountGovernanceService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<UserGovernanceSummaryDto>> ListUsersAsync(string? search = null, int take = 200);
|
||||||
|
Task SetAdminAsync(Guid actorUserId, Guid targetUserId, bool enabled);
|
||||||
|
Task SetDisabledAsync(Guid actorUserId, Guid targetUserId, bool disabled);
|
||||||
|
Task ResetPasswordAsync(Guid actorUserId, Guid targetUserId, string newPassword);
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using MemberCenter.Application.Models.Account;
|
||||||
|
|
||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IAccountProvisioningService
|
||||||
|
{
|
||||||
|
Task<AccountProvisioningResult> RegisterLocalAsync(string email, string password);
|
||||||
|
Task<AccountProvisioningResult> ProvisionExternalLoginAsync(string loginProvider, string providerKey, string? email, bool emailVerified);
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IAdminPermissionChecker
|
||||||
|
{
|
||||||
|
Task<bool> HasPermissionAsync(ClaimsPrincipal user, string permissionKey, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IAdminPermissionSeeder
|
||||||
|
{
|
||||||
|
Task EnsureDefaultsAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IAuditLogWriter
|
||||||
|
{
|
||||||
|
Task WriteAsync(string actorType, Guid? actorId, string action, object payload);
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IAuthResourceRegistryService
|
||||||
|
{
|
||||||
|
Task EnsureDefaultsAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<string>> ResolveAudiencesAsync(IEnumerable<string> scopes, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<string>> GetAllowedScopesForUsageAsync(string usage, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IEmailSender
|
||||||
|
{
|
||||||
|
Task<int> SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null);
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
using MemberCenter.Application.Models.Profile;
|
||||||
using MemberCenter.Application.Models.Newsletter;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/MemberCenter.Application/Abstractions/IProfileService.cs
Normal file
15
src/MemberCenter.Application/Abstractions/IProfileService.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IUserConsentService
|
||||||
|
{
|
||||||
|
Task RecordRegistrationConsentAsync(
|
||||||
|
Guid userId,
|
||||||
|
string registrationMethod,
|
||||||
|
string? ipAddress,
|
||||||
|
string? userAgent,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
106
src/MemberCenter.Application/Constants/AdminPermissions.cs
Normal file
106
src/MemberCenter.Application/Constants/AdminPermissions.cs
Normal 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);
|
||||||
@ -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";
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
namespace MemberCenter.Application.Models.Account;
|
||||||
|
|
||||||
|
public sealed record AccountProvisioningResult(
|
||||||
|
bool Succeeded,
|
||||||
|
Guid? UserId,
|
||||||
|
string? Email,
|
||||||
|
bool EmailConfirmed,
|
||||||
|
bool CreatedUser,
|
||||||
|
bool LinkedExternalLogin,
|
||||||
|
int LinkedSubscriptionsCount,
|
||||||
|
IReadOnlyList<string> Errors);
|
||||||
@ -5,4 +5,5 @@ public sealed record AuditLogDto(
|
|||||||
string ActorType,
|
string ActorType,
|
||||||
Guid? ActorId,
|
Guid? ActorId,
|
||||||
string Action,
|
string Action,
|
||||||
|
string? PayloadJson,
|
||||||
DateTimeOffset CreatedAt);
|
DateTimeOffset CreatedAt);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
14
src/MemberCenter.Application/Models/Admin/SmtpSettingsDto.cs
Normal file
14
src/MemberCenter.Application/Models/Admin/SmtpSettingsDto.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
namespace MemberCenter.Application.Models.Admin;
|
||||||
|
|
||||||
|
public sealed record SmtpSettingsDto(
|
||||||
|
string PublicBaseUrl,
|
||||||
|
string RelayHost,
|
||||||
|
int RelayPort,
|
||||||
|
bool UseTls,
|
||||||
|
bool UseSsl,
|
||||||
|
int TimeoutSeconds,
|
||||||
|
string Username,
|
||||||
|
string Password,
|
||||||
|
bool HasPassword,
|
||||||
|
string SenderName,
|
||||||
|
string SenderEmail);
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace MemberCenter.Application.Models.Admin;
|
||||||
|
|
||||||
|
public sealed record TenantManagerDto(
|
||||||
|
Guid TenantId,
|
||||||
|
Guid UserId,
|
||||||
|
string Email,
|
||||||
|
string DisplayName,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
namespace MemberCenter.Application.Models.Admin;
|
||||||
|
|
||||||
|
public sealed record TokenLifetimeSettingsDto(
|
||||||
|
int AccessTokenMinutes,
|
||||||
|
int RefreshTokenDays);
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
namespace MemberCenter.Application.Models.Admin;
|
||||||
|
|
||||||
|
public sealed record UserGovernanceSummaryDto(
|
||||||
|
Guid UserId,
|
||||||
|
string Email,
|
||||||
|
string? LastName,
|
||||||
|
string? FirstName,
|
||||||
|
string? NickName,
|
||||||
|
bool EmailConfirmed,
|
||||||
|
bool IsAdmin,
|
||||||
|
bool IsSuperuser,
|
||||||
|
bool IsDisabled,
|
||||||
|
bool IsBlacklisted,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? LastLoginAt,
|
||||||
|
DateTimeOffset? LastSeenAt,
|
||||||
|
DateTimeOffset? DisabledAt,
|
||||||
|
string? DisabledBy);
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace MemberCenter.Application.Models.Email;
|
||||||
|
|
||||||
|
public sealed record EmailTemplate(
|
||||||
|
string Subject,
|
||||||
|
string TextBody,
|
||||||
|
string HtmlBody);
|
||||||
@ -0,0 +1,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);
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
namespace MemberCenter.Application.Models.Profile;
|
||||||
|
|
||||||
|
public sealed record SaveUserProfileRequest(
|
||||||
|
string LastName,
|
||||||
|
string FirstName,
|
||||||
|
string? NickName,
|
||||||
|
string? MobilePhone,
|
||||||
|
string? LandlinePhone,
|
||||||
|
DateOnly? DateOfBirth,
|
||||||
|
string Gender,
|
||||||
|
string? CompanyName,
|
||||||
|
string? Department,
|
||||||
|
string? JobTitle,
|
||||||
|
string? CompanyPhone,
|
||||||
|
string? TaxId,
|
||||||
|
string? InvoiceTitle,
|
||||||
|
string? Remark);
|
||||||
@ -0,0 +1,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);
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
namespace MemberCenter.Application.Models.Profile;
|
||||||
|
|
||||||
|
public sealed record UserProfileDto(
|
||||||
|
Guid UserId,
|
||||||
|
string Email,
|
||||||
|
string LastName,
|
||||||
|
string FirstName,
|
||||||
|
string? NickName,
|
||||||
|
string? MobilePhone,
|
||||||
|
string? LandlinePhone,
|
||||||
|
DateOnly? DateOfBirth,
|
||||||
|
string Gender,
|
||||||
|
string? CompanyName,
|
||||||
|
string? Department,
|
||||||
|
string? JobTitle,
|
||||||
|
string? CompanyPhone,
|
||||||
|
string? TaxId,
|
||||||
|
string? InvoiceTitle,
|
||||||
|
string? Remark);
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
namespace MemberCenter.Application.Models.Profile;
|
||||||
|
|
||||||
|
public sealed record UserProfileImageDto(
|
||||||
|
Guid UserId,
|
||||||
|
byte[] Content,
|
||||||
|
string ContentType,
|
||||||
|
int Width,
|
||||||
|
int Height,
|
||||||
|
long SizeBytes,
|
||||||
|
DateTimeOffset UpdatedAt);
|
||||||
@ -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);
|
||||||
14
src/MemberCenter.Domain/Entities/AdminPermission.cs
Normal file
14
src/MemberCenter.Domain/Entities/AdminPermission.cs
Normal 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();
|
||||||
|
}
|
||||||
10
src/MemberCenter.Domain/Entities/AdminRolePermission.cs
Normal file
10
src/MemberCenter.Domain/Entities/AdminRolePermission.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
namespace MemberCenter.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class AuthClientUsagePermission
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Usage { get; set; } = string.Empty;
|
||||||
|
public string Scope { get; set; } = string.Empty;
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
16
src/MemberCenter.Domain/Entities/AuthResource.cs
Normal file
16
src/MemberCenter.Domain/Entities/AuthResource.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
namespace MemberCenter.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class AuthResource
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Audience { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public bool RequireTenant { get; set; }
|
||||||
|
public bool AllowDelegatedToken { get; set; }
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public List<AuthResourceScope> Scopes { get; set; } = new();
|
||||||
|
}
|
||||||
13
src/MemberCenter.Domain/Entities/AuthResourceScope.cs
Normal file
13
src/MemberCenter.Domain/Entities/AuthResourceScope.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace MemberCenter.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class AuthResourceScope
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid ResourceId { get; set; }
|
||||||
|
public string Scope { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public AuthResource? Resource { get; set; }
|
||||||
|
}
|
||||||
18
src/MemberCenter.Domain/Entities/FileAccessDownloadToken.cs
Normal file
18
src/MemberCenter.Domain/Entities/FileAccessDownloadToken.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
namespace MemberCenter.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class FileAccessDownloadToken
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string TokenHash { get; set; } = string.Empty;
|
||||||
|
public Guid TenantId { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string? FileId { get; set; }
|
||||||
|
public string? ObjectKey { get; set; }
|
||||||
|
public string Method { get; set; } = "GET";
|
||||||
|
public string Scope { get; set; } = "files:download.read";
|
||||||
|
public string? IssuedByClientId { get; set; }
|
||||||
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
|
public DateTimeOffset? RevokedAt { get; set; }
|
||||||
|
public DateTimeOffset? LastValidatedAt { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/MemberCenter.Domain/Entities/TenantManager.cs
Normal file
11
src/MemberCenter.Domain/Entities/TenantManager.cs
Normal 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; }
|
||||||
|
}
|
||||||
24
src/MemberCenter.Domain/Entities/UserAddress.cs
Normal file
24
src/MemberCenter.Domain/Entities/UserAddress.cs
Normal 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;
|
||||||
|
}
|
||||||
13
src/MemberCenter.Domain/Entities/UserConsent.cs
Normal file
13
src/MemberCenter.Domain/Entities/UserConsent.cs
Normal 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;
|
||||||
|
}
|
||||||
21
src/MemberCenter.Domain/Entities/UserProfile.cs
Normal file
21
src/MemberCenter.Domain/Entities/UserProfile.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
namespace MemberCenter.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class UserProfile
|
||||||
|
{
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
public string? NickName { get; set; }
|
||||||
|
public string? MobilePhone { get; set; }
|
||||||
|
public string? LandlinePhone { get; set; }
|
||||||
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
|
public string Gender { get; set; } = "unspecified";
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? Department { get; set; }
|
||||||
|
public string? JobTitle { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? TaxId { get; set; }
|
||||||
|
public string? InvoiceTitle { get; set; }
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
12
src/MemberCenter.Domain/Entities/UserProfileImage.cs
Normal file
12
src/MemberCenter.Domain/Entities/UserProfileImage.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,159 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUserProfilesAndAddresses : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "DisabledAt",
|
||||||
|
table: "users",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "DisabledBy",
|
||||||
|
table: "users",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "LastLoginAt",
|
||||||
|
table: "users",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "LastSeenAt",
|
||||||
|
table: "users",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "user_addresses",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
RecipientName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
RecipientPhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
CountryCode = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: false),
|
||||||
|
PostalCode = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||||
|
StateRegion = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
District = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
AddressLine1 = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||||
|
AddressLine2 = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
|
||||||
|
CompanyName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
Usage = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "shipping"),
|
||||||
|
IsDefault = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
AddressMetaJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_user_addresses", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_user_addresses_users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "user_profiles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
LastName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
FirstName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
NickName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
MobilePhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
LandlinePhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
DateOfBirth = table.Column<DateOnly>(type: "date", nullable: true),
|
||||||
|
Gender = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "unspecified"),
|
||||||
|
CompanyName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
Department = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
JobTitle = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
CompanyPhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
TaxId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
InvoiceTitle = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
Remark = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_user_profiles", x => x.UserId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_user_profiles_users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
INSERT INTO user_profiles ("UserId", "LastName", "FirstName", "Gender", "UpdatedAt")
|
||||||
|
SELECT "Id", '', '', 'unspecified', now()
|
||||||
|
FROM users
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_profiles
|
||||||
|
WHERE user_profiles."UserId" = users."Id"
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_user_addresses_user_id",
|
||||||
|
table: "user_addresses",
|
||||||
|
column: "UserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_user_addresses_user_id_usage",
|
||||||
|
table: "user_addresses",
|
||||||
|
columns: new[] { "UserId", "Usage" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ux_user_addresses_default_per_usage",
|
||||||
|
table: "user_addresses",
|
||||||
|
columns: new[] { "UserId", "Usage", "IsDefault" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"IsDefault\" = true");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "user_addresses");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "user_profiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DisabledAt",
|
||||||
|
table: "users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DisabledBy",
|
||||||
|
table: "users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastLoginAt",
|
||||||
|
table: "users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastSeenAt",
|
||||||
|
table: "users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
using MemberCenter.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(MemberCenterDbContext))]
|
||||||
|
[Migration("20260417043000_ChangeUserAddressDefaultToSinglePerUser")]
|
||||||
|
public partial class ChangeUserAddressDefaultToSinglePerUser : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
WITH ranked_defaults AS (
|
||||||
|
SELECT "Id",
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY "UserId"
|
||||||
|
ORDER BY "UpdatedAt" DESC, "CreatedAt" DESC, "Id"
|
||||||
|
) AS rn
|
||||||
|
FROM user_addresses
|
||||||
|
WHERE "IsDefault" = true
|
||||||
|
)
|
||||||
|
UPDATE user_addresses ua
|
||||||
|
SET "IsDefault" = false,
|
||||||
|
"UpdatedAt" = now()
|
||||||
|
FROM ranked_defaults rd
|
||||||
|
WHERE ua."Id" = rd."Id"
|
||||||
|
AND rd.rn > 1;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
WITH users_without_default AS (
|
||||||
|
SELECT ua."UserId"
|
||||||
|
FROM user_addresses ua
|
||||||
|
GROUP BY ua."UserId"
|
||||||
|
HAVING BOOL_OR(ua."IsDefault") = false
|
||||||
|
),
|
||||||
|
replacement AS (
|
||||||
|
SELECT ranked."Id"
|
||||||
|
FROM (
|
||||||
|
SELECT ua."Id",
|
||||||
|
ua."UserId",
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY ua."UserId"
|
||||||
|
ORDER BY ua."UpdatedAt" DESC, ua."CreatedAt" DESC, ua."Id"
|
||||||
|
) AS rn
|
||||||
|
FROM user_addresses ua
|
||||||
|
INNER JOIN users_without_default uwd ON uwd."UserId" = ua."UserId"
|
||||||
|
) ranked
|
||||||
|
WHERE ranked.rn = 1
|
||||||
|
)
|
||||||
|
UPDATE user_addresses ua
|
||||||
|
SET "IsDefault" = true,
|
||||||
|
"UpdatedAt" = now()
|
||||||
|
FROM replacement r
|
||||||
|
WHERE ua."Id" = r."Id";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ux_user_addresses_default_per_usage",
|
||||||
|
table: "user_addresses");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ux_user_addresses_default_per_user",
|
||||||
|
table: "user_addresses",
|
||||||
|
columns: new[] { "UserId", "IsDefault" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"IsDefault\" = true");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ux_user_addresses_default_per_user",
|
||||||
|
table: "user_addresses");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ux_user_addresses_default_per_usage",
|
||||||
|
table: "user_addresses",
|
||||||
|
columns: new[] { "UserId", "Usage", "IsDefault" },
|
||||||
|
unique: true,
|
||||||
|
filter: "\"IsDefault\" = true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1215
src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.Designer.cs
generated
Normal file
1215
src/MemberCenter.Infrastructure/Persistence/Migrations/20260422154035_AddAuthResourceRegistry.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAuthResourceRegistry : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "auth_client_usage_permissions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Usage = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_auth_client_usage_permissions", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "auth_resources",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
Audience = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
RequireTenant = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
AllowDelegatedToken = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_auth_resources", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "auth_resource_scopes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ResourceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_auth_resource_scopes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_auth_resource_scopes_auth_resources_ResourceId",
|
||||||
|
column: x => x.ResourceId,
|
||||||
|
principalTable: "auth_resources",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_auth_client_usage_permissions_Usage_Scope",
|
||||||
|
table: "auth_client_usage_permissions",
|
||||||
|
columns: new[] { "Usage", "Scope" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_auth_resource_scopes_scope",
|
||||||
|
table: "auth_resource_scopes",
|
||||||
|
column: "Scope");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_auth_resource_scopes_ResourceId_Scope",
|
||||||
|
table: "auth_resource_scopes",
|
||||||
|
columns: new[] { "ResourceId", "Scope" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_auth_resources_Audience",
|
||||||
|
table: "auth_resources",
|
||||||
|
column: "Audience",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_auth_resources_Name",
|
||||||
|
table: "auth_resources",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "auth_client_usage_permissions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "auth_resource_scopes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "auth_resources");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFileAccessDownloadTokens : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "file_access_download_tokens",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
TokenHash = table.Column<string>(type: "text", nullable: false),
|
||||||
|
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FileId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
ObjectKey = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
Method = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false, defaultValue: "GET"),
|
||||||
|
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false, defaultValue: "files:download.read"),
|
||||||
|
IssuedByClientId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
LastValidatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_file_access_download_tokens", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_file_access_download_tokens_tenants_TenantId",
|
||||||
|
column: x => x.TenantId,
|
||||||
|
principalTable: "tenants",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_file_access_download_tokens_users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_file_access_download_tokens_expires_at",
|
||||||
|
table: "file_access_download_tokens",
|
||||||
|
column: "ExpiresAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_file_access_download_tokens_tenant_user",
|
||||||
|
table: "file_access_download_tokens",
|
||||||
|
columns: new[] { "TenantId", "UserId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_file_access_download_tokens_TokenHash",
|
||||||
|
table: "file_access_download_tokens",
|
||||||
|
column: "TokenHash",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_file_access_download_tokens_UserId",
|
||||||
|
table: "file_access_download_tokens",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "file_access_download_tokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1321
src/MemberCenter.Infrastructure/Persistence/Migrations/20260430200729_AddDataProtectionKeys.Designer.cs
generated
Normal file
1321
src/MemberCenter.Infrastructure/Persistence/Migrations/20260430200729_AddDataProtectionKeys.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDataProtectionKeys : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DataProtectionKeys",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
FriendlyName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Xml = table.Column<string>(type: "text", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DataProtectionKeys");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1406
src/MemberCenter.Infrastructure/Persistence/Migrations/20260703055656_AddAdminPermissions.Designer.cs
generated
Normal file
1406
src/MemberCenter.Infrastructure/Persistence/Migrations/20260703055656_AddAdminPermissions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user