From e77fdec76b262895ca7797626d5b00202eed6875 Mon Sep 17 00:00:00 2001 From: warrenchen Date: Fri, 24 Apr 2026 16:27:31 +0900 Subject: [PATCH] feat: add FileAccessDownloadToken entity and migration - Introduced the FileAccessDownloadToken entity with properties for managing file access tokens. - Created a migration to add the file_access_download_tokens table to the database with appropriate constraints and indexes. --- .env.example | 1 + docs/DESIGN.md | 25 +- docs/FLOWS.md | 15 +- docs/MEMBER_UPGRADE_PLAN.md | 17 +- docs/OPENAPI.md | 18 +- docs/TEST_SITE.md | 21 +- docs/UI.md | 2 +- docs/openapi.yaml | 90 +- .../Contracts/FileAccessRequests.cs | 18 + .../Controllers/FileAccessController.cs | 204 +++ src/MemberCenter.Api/Program.cs | 2 + .../Entities/FileAccessDownloadToken.cs | 18 + .../Persistence/MemberCenterDbContext.cs | 25 + ...04_AddFileAccessDownloadTokens.Designer.cs | 1302 +++++++++++++++++ ...60423055404_AddFileAccessDownloadTokens.cs | 78 + .../MemberCenterDbContextModelSnapshot.cs | 93 +- .../Controllers/HomeController.cs | 26 + .../Models/MemberCenterTestOptions.cs | 2 + .../Views/Home/Index.cshtml | 5 + src/MemberCenter.TestSite/appsettings.json | 2 + .../Controllers/AccountController.cs | 27 +- 21 files changed, 1940 insertions(+), 51 deletions(-) create mode 100644 src/MemberCenter.Api/Contracts/FileAccessRequests.cs create mode 100644 src/MemberCenter.Api/Controllers/FileAccessController.cs create mode 100644 src/MemberCenter.Domain/Entities/FileAccessDownloadToken.cs create mode 100644 src/MemberCenter.Infrastructure/Persistence/Migrations/20260423055404_AddFileAccessDownloadTokens.Designer.cs create mode 100644 src/MemberCenter.Infrastructure/Persistence/Migrations/20260423055404_AddFileAccessDownloadTokens.cs diff --git a/.env.example b/.env.example index 2122eb6..200641a 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgr Auth__Issuer=http://localhost:7850/ Auth__WebLoginUrl=http://localhost:5080/account/login Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/ +Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/ Auth__MemberCenterAudience=member_center_api Auth__SendEngineAudience=send_engine_api SendEngine__BaseUrl=http://localhost:6060 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 697ab61..d7410f2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -79,6 +79,8 @@ - id, email, tenant_id, token_hash, purpose, expires_at, consumed_at - unsubscribe_tokens - id, subscription_id, token_hash, expires_at, consumed_at +- file_access_download_tokens + - id, token_hash, tenant_id, user_id, file_id, object_key, method, scope, issued_by_client_id, expires_at, revoked_at, last_validated_at, created_at - audit_logs - id, actor_type, actor_id, action, payload, created_at - system_flags @@ -196,17 +198,16 @@ - access agent 以 JWKS 驗簽 JWT,並驗 `iss/aud/exp/scope/tenant_id` 2) Download 採 delegated short-lived token: - client 向 `A service` 要求下載 - - `A service` 驗商業規則與檔案權限 - - `A service` 向新 File Access 專案取得短效 download token,或由 File Access 專案提供簽發元件 + - `A service` 以自己的商業邏輯驗證 Member Center user 身份與檔案權限 + - `A service` 以 `files:download.delegate` 向 Member Center 申請短效 download token - client 帶短效 token 直接向 access agent / file space 請求檔案 - - access agent 驗 token 後放行 + - access agent 以 `files:download.read` 回打 Member Center 驗證 token 與實際 GET request 邊界一致後放行 規則: - 不直接將一般 S2S access token 暴露給 client 作為下載 token - download token 應至少帶: - - `aud=file_access_api` - - `scope=files:download.read` - `tenant_id` + - `user_id` - `file_id` 或 `object_key` - `method=GET` - 短效 `exp` @@ -216,11 +217,7 @@ - `scope=files:upload.write` - `tenant_id` - access agent 應驗: - - JWT signature(JWKS) - - `iss` - - `aud` - - `exp` - - `scope` + - Member Center validation endpoint 回傳 `active=true` - `tenant_id` - 檔案識別與 method 是否與 token 一致 @@ -256,7 +253,7 @@ resource registry 至少需定義: 設計原則: - resource registry 由 DB 與管理 UI 管理非敏感欄位 - `TokenController` 依 scope 與 usage 對照 resource registry 計算 resources / audiences -- delegated token 與一般 S2S token 共用同一套 resource registry,不重複維護 audience 規則 +- delegated download token 由 Member Center 簽發與線上驗證;一般 S2S token 仍由 resource registry 決定 audience - 對外資料讀寫授權完全由 scope 決定,只要 client 被授權該 scope 即可存取對應能力 目前實作狀態: @@ -264,7 +261,7 @@ resource registry 至少需定義: - 預設 seed 已包含 `member_center_api`、`send_engine_api`、`file_access_api` - OAuth client usage-scope matrix 已由 DB 驅動,包含 `file_api` - resource registry 管理 UI 仍待補 -- delegated download token 發放流程不放在 Member Center,會在新 File Access 專案實作 +- delegated download token issuing / validation 已由 Member Center API 負責,File Access agent 以 validation endpoint 確認 client GET request 帶來的 token 是否有效 ## 7. API 介面(草案) - GET `/oauth/authorize` @@ -321,7 +318,7 @@ resource registry 至少需定義: - token 內含 `tenant_id` 與 scope - Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS) - 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫 -- File Access 與未來外部服務亦沿用此模型;File Access 的 delegated token 規則由新 File Access 專案處理 +- File Access 與未來外部服務亦沿用此模型;File Access delegated download token 由 Member Center 簽發與線上驗證 - 若其他服務要讀取會員個資,應只授予必要的 profile read scopes,不應沿用過寬的 `profile` OIDC scope - service API 為主要整合模式,存取控制以 scopes 為唯一授權來源 @@ -331,7 +328,7 @@ resource registry 至少需定義: - `subscription.linked_to_user` 事件已發送 - 安全設定頁(access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime - Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足 -- resource registry 已完成 DB 驅動第一版;file access delegated token issuing 不屬於 Member Center,會在新專案實作 +- resource registry 已完成 DB 驅動第一版;file access delegated token issuing / validation 已由 Member Center API 實作 ## 8. 安全與合規 - 密碼強度與防暴力破解(rate limit + lockout) diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 025c400..e3e87cb 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -18,6 +18,13 @@ - [UI] 若 Web 與 API 不同 origin,需設定 `Auth:WebLoginUrl`,且 Web 端需允許導回 `Auth:Issuer` 或 `Auth:AllowedLoginReturnUrlPrefixes` - [UI] 若 Web 與 API 位於不同子網域,需設定 `Auth:CookieDomain`,讓 authorize endpoint 可讀取 Web login cookie +## F-02a 登出(外站 direct logout) +- [UI] 外站將瀏覽器導向 Member Center Web `/account/logout` +- [UI] Member Center 清除 Web login cookie / session +- [UI] 若帶 `returnUrl`,Member Center 僅允許導回本站 local URL 或 allowlist 內的外站 URL +- [UI] 登出完成後導回外站 callback +- [UI] 外站 callback 清除本站 session / token + ## F-02b 內容站台呼叫 Send Engine(Client Credentials + JWT 驗簽) - [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token(`usage=send_api`) - [API] 內容站台帶 Bearer token 呼叫 Send Engine 建立發送任務 @@ -33,12 +40,12 @@ ## F-02d 檔案下載(A service -> client -> File Space) - [API] client 向 `A service` 請求下載檔案 -- [API] `A service` 驗證該 request 是否可讀取指定檔案 -- [API] `A service` 向新 File Access 專案取得短效 download token,或使用 File Access 專案提供的簽發元件 -- [API] download token 需至少綁定 `tenant_id + file_id/object_key + method=GET + exp` +- [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 驗 token 後放行 +- [API] access agent 以 `files:download.read` 呼叫 Member Center `POST /file-access/download-tokens/validate`,確認 token 與實際 GET request 邊界一致後放行 註記:下載流程不直接暴露一般 S2S token 給 client。 diff --git a/docs/MEMBER_UPGRADE_PLAN.md b/docs/MEMBER_UPGRADE_PLAN.md index c20d794..31e7d3c 100644 --- a/docs/MEMBER_UPGRADE_PLAN.md +++ b/docs/MEMBER_UPGRADE_PLAN.md @@ -163,7 +163,7 @@ - `MemberCenter.Api` 已有 `GET /user/profile`,但目前只提供基礎欄位,不足以支撐其他服務查詢完整會員資料。 - `docs/DESIGN.md` / `docs/FLOWS.md` 已定義 File Access 流程: - upload: `client_credentials` 取得 upload token 後由外部服務直連 file space - - download: 由業務服務驗權後,交由新 File Access 專案簽發或管理短效 delegated download token + - 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 @@ -177,7 +177,7 @@ - profile / addresses / subscriptions 畫面與驗證目前為最小可用版本,尚未完成 UI refinement。 ### 待補項 -- File Access 的 OAuth client usage、scope、audience 已落地;delegated token 發放流程不放在 Member Center,會在新 File Access 專案實作。 +- File Access 的 OAuth client usage、scope、audience 已落地;delegated download token issuing / validation 已落在 Member Center。 - Token resource / audience 已抽象為 registry;後續需補 resource registry 管理畫面。 - Email 樣板正式文案與會員 / 後台 UI 細節仍待整理。 - rate limit 仍缺少 `one-click unsubscribe token` 與更細的風控觀測。 @@ -628,9 +628,10 @@ - `files:metadata.read` - 新增 audience: - `file_access_api` -- File Access 新專案需實作 delegated token 規則: +- Member Center 需實作 delegated download token 規則: - 下載 token 必須短效 - 必須綁定 `tenant_id` + - 必須綁定 `user_id` - 必須綁定 `file_id` 或 `object_key` - 必須綁定 `method` - 不可直接重用一般 S2S access token 給 client @@ -643,7 +644,7 @@ - scopes - client usages - 是否需要 `tenant_id` - - 是否允許 delegated token(供 File Access 判斷,不代表 Member Center 負責簽發檔案下載 token) + - 是否允許 delegated token(供 Member Center 判斷該 resource 是否可簽發短效下載 token) - 新增資源服務時,只擴充 registry 與授權規則,不再修改硬編碼 audience 分支。 - 所有外部資料存取均以 scope 作為唯一授權依據。 @@ -654,13 +655,13 @@ - `file_api` -> `file_access_api` - scope 用於細粒度權限控制。 - `TokenController` 依 scope 與 usage 對照 resource registry 計算 `resources/audiences`。 -- delegated download token issuing 屬於新 File Access 專案責任;Member Center 只負責發出可呼叫 File Access 的 OAuth access token。 +- delegated download token issuing / validation 屬於 Member Center 責任;File Access agent 負責把 client GET request 的 token 與 `tenant_id + file_id/object_key + method` 帶回 Member Center 驗證。 目前進度: - Send Engine、Member Center profile/newsletter scopes、File Access scopes 已進 registry - `TokenController` 已以 registry 解析 audiences - OAuth client usage-scope matrix 已以 `auth_client_usage_permissions` 驅動 -- File Access delegated token issuing、流程測試與 sample code 會在新 File Access 專案實作 +- File Access delegated token issuing / validation API 已落在 Member Center;File Access agent 尚需串接 validation endpoint ### 9. 審計紀錄 狀態:`大致完成,少數治理事件待續作` @@ -768,13 +769,13 @@ ### API - 若設定畫面走 API,需新增設定讀寫端點。 - 若帳號管理後台走 API,需新增角色管理與帳號查詢端點。 -- File Access 的 resource / audience mapping 與 file scopes 已完成;流程測試與 sample code 會在新 File Access 專案實作。 +- 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 不屬於 Member Center。 +- token resource resolver 已抽到 registry service;File Access delegated token issuer / validator 已落在 Member Center。 - 新增會員基本資料與地址簿的資料模型與服務層。 ### Installer diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index f76f4be..990ab1b 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -162,28 +162,26 @@ - 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 至少需帶: - - `aud=file_access_api` - - `scope=files:download.read` - `tenant_id` + - `user_id` - `file_id` 或 `object_key` - `method=GET` - 短效 `exp` - 建議 `jti` -- access agent 至少應驗: - - JWT signature(JWKS) - - `iss` - - `aud` - - `exp` - - `scope` +- access agent 至少應送回 Member Center 驗: + - opaque download token - `tenant_id` - token 內檔案識別與實際 request 是否一致 - token 內 method 與實際 request 是否一致 補充: - File Access 與 Send Engine 同屬「外部資源服務」,驗證模型一致 -- 差異在於 File Access download token 為 delegated short-lived token,而非一般 client credentials token -- delegated download token issuing、流程測試與 sample code 會在新 File Access 專案實作,不屬於 Member Center API +- 差異在於 File Access download token 為 Member Center 簽發的 delegated short-lived opaque token,而非一般 client credentials token +- Member Center 負責 delegated download token issuing 與 validation;File Access agent 負責把實際 GET request 的 tenant/file/method 邊界帶回 Member Center 驗證 ### 回寫原因碼(Send Engine -> Member Center) - `hard_bounce` diff --git a/docs/TEST_SITE.md b/docs/TEST_SITE.md index f23cce1..443ea68 100644 --- a/docs/TEST_SITE.md +++ b/docs/TEST_SITE.md @@ -22,6 +22,7 @@ http://localhost:5243 { "MemberCenter": { "ApiBaseUrl": "http://localhost:7850", + "WebBaseUrl": "http://localhost:5080", "WebLoginClientId": "", "ServiceClientId": "", "ServiceClientSecret": "" @@ -45,6 +46,7 @@ service OAuth client: ```text Auth__WebLoginUrl= Auth__AllowedLoginReturnUrlPrefixes= +Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/ Auth__CookieDomain= ``` @@ -53,14 +55,15 @@ Auth__CookieDomain= 測試站目前包含: 1. Redirect login 拿 token -2. API login 拿 token -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` -9. service token 呼叫 `GET /user/profile/by-email` -10. service token 呼叫 `GET /user/addresses/by-email` +2. Redirect logout 清除 Member Center Web session 並回到 TestSite +3. API login 拿 token +4. `GET /user/profile` +5. `POST /user/profile` +6. `GET /user/addresses` +7. `POST /user/addresses` +8. `GET /user/subscriptions` +9. `POST /user/subscriptions/{id}/unsubscribe` +10. service token 呼叫 `GET /user/profile/by-email` +11. service token 呼叫 `GET /user/addresses/by-email` 測試站只做 happy path,不取代完整自動化測試。 diff --git a/docs/UI.md b/docs/UI.md index cb41ad0..f8da928 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -43,7 +43,7 @@ ### 會員端(統一 UI) - UC-01 註冊會員: `/account/register` - UC-02 登入: `/account/login` -- UC-03 登出: `POST /account/logout` +- UC-03 登出: `POST /account/logout`(外站 direct logout 可導向 `GET /account/logout?returnUrl=...`) - UC-04 忘記密碼 / 重設密碼: `/account/forgotpassword`, `/account/resetpassword` - UC-04.1 已登入修改密碼: `/account/changepassword` - UC-05 Email 驗證: `/account/verifyemail?email=...&token=...` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 12ddbe5..a31dfdb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -138,7 +138,7 @@ paths: /auth/logout: post: - summary: Logout (revoke refresh token) + summary: Logout current authenticated session security: - BearerAuth: [] requestBody: @@ -810,6 +810,48 @@ paths: '204': description: Deleted + /file-access/download-tokens: + post: + summary: Issue delegated short-lived file download token + description: | + Called by a service after it has verified the Member Center user is allowed to download the requested file. + Requires `files:download.delegate`. + security: [{ BearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IssueFileDownloadTokenRequest' + responses: + '200': + description: Token issued + content: + application/json: + schema: + $ref: '#/components/schemas/FileDownloadTokenResponse' + + /file-access/download-tokens/validate: + post: + summary: Validate delegated file download token + description: | + Called by File Access agent with the token from the client GET request and the actual request boundary. + Requires `files:download.read`. + security: [{ BearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateFileDownloadTokenRequest' + responses: + '200': + description: Validation result + content: + application/json: + schema: + $ref: '#/components/schemas/FileDownloadTokenValidationResponse' + components: securitySchemes: OAuth2: @@ -927,6 +969,52 @@ components: token: { type: string } new_password: { type: string } + IssueFileDownloadTokenRequest: + type: object + required: [tenant_id, user_id] + properties: + tenant_id: { type: string, format: uuid } + user_id: { type: string, format: uuid } + file_id: { type: string, nullable: true } + object_key: { type: string, nullable: true } + method: { type: string, enum: [GET], default: GET } + expires_in_seconds: { type: integer, minimum: 30, maximum: 900, default: 300 } + + FileDownloadTokenResponse: + type: object + properties: + token: { type: string } + token_type: { type: string, example: file_download } + expires_at: { type: string, format: date-time } + tenant_id: { type: string, format: uuid } + user_id: { type: string, format: uuid } + file_id: { type: string, nullable: true } + object_key: { type: string, nullable: true } + method: { type: string, example: GET } + scope: { type: string, example: files:download.read } + + ValidateFileDownloadTokenRequest: + type: object + required: [token, tenant_id] + properties: + token: { type: string } + tenant_id: { type: string, format: uuid } + file_id: { type: string, nullable: true } + object_key: { type: string, nullable: true } + method: { type: string, enum: [GET], default: GET } + + FileDownloadTokenValidationResponse: + type: object + properties: + active: { type: boolean } + tenant_id: { type: string, format: uuid } + user_id: { type: string, format: uuid } + file_id: { type: string, nullable: true } + object_key: { type: string, nullable: true } + method: { type: string, example: GET } + scope: { type: string, example: files:download.read } + expires_at: { type: string, format: date-time } + UserProfile: type: object properties: diff --git a/src/MemberCenter.Api/Contracts/FileAccessRequests.cs b/src/MemberCenter.Api/Contracts/FileAccessRequests.cs new file mode 100644 index 0000000..5a94b21 --- /dev/null +++ b/src/MemberCenter.Api/Contracts/FileAccessRequests.cs @@ -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); diff --git a/src/MemberCenter.Api/Controllers/FileAccessController.cs b/src/MemberCenter.Api/Controllers/FileAccessController.cs new file mode 100644 index 0000000..036716c --- /dev/null +++ b/src/MemberCenter.Api/Controllers/FileAccessController.cs @@ -0,0 +1,204 @@ +using System.Security.Cryptography; +using System.Text; +using MemberCenter.Api.Contracts; +using MemberCenter.Domain.Entities; +using MemberCenter.Infrastructure.Identity; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; +using OpenIddict.Abstractions; + +namespace MemberCenter.Api.Controllers; + +[ApiController] +[Route("file-access/download-tokens")] +public sealed class FileAccessController : ControllerBase +{ + private const string DownloadScope = "files:download.read"; + private const string TokenPurpose = "file_access_download"; + private static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(5); + private static readonly TimeSpan MinimumLifetime = TimeSpan.FromSeconds(30); + private static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(15); + + private readonly MemberCenterDbContext _dbContext; + private readonly UserManager _userManager; + + public FileAccessController(MemberCenterDbContext dbContext, UserManager userManager) + { + _dbContext = dbContext; + _userManager = userManager; + } + + [Authorize(Policy = "FilesDownloadDelegate")] + [HttpPost("")] + public async Task Issue([FromBody] IssueFileDownloadTokenRequest request) + { + var validationError = ValidateBoundary(request.TenantId, request.FileId, request.ObjectKey, request.Method); + if (validationError is not null) + { + return BadRequest(validationError); + } + + if (!IsTenantAllowed(request.TenantId)) + { + return BadRequest("tenant_id does not match token tenant scope."); + } + + if (request.UserId == Guid.Empty) + { + return BadRequest("user_id is required."); + } + + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null || user.DisabledAt.HasValue) + { + return BadRequest("user_id is invalid or disabled."); + } + + var lifetime = ResolveLifetime(request.ExpiresInSeconds); + var expiresAt = DateTimeOffset.UtcNow.Add(lifetime); + var token = $"fdt_{WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32))}"; + + var record = new FileAccessDownloadToken + { + Id = Guid.NewGuid(), + TokenHash = HashToken(token), + TenantId = request.TenantId, + UserId = request.UserId, + FileId = NormalizeOptional(request.FileId), + ObjectKey = NormalizeOptional(request.ObjectKey), + Method = "GET", + Scope = DownloadScope, + IssuedByClientId = User.GetClaim(OpenIddictConstants.Claims.ClientId), + ExpiresAt = expiresAt + }; + + _dbContext.FileAccessDownloadTokens.Add(record); + await _dbContext.SaveChangesAsync(HttpContext.RequestAborted); + + return Ok(new + { + token, + token_type = "file_download", + expires_at = expiresAt, + tenant_id = record.TenantId, + user_id = record.UserId, + file_id = record.FileId, + object_key = record.ObjectKey, + method = record.Method, + scope = record.Scope + }); + } + + [Authorize(Policy = "FilesDownloadRead")] + [HttpPost("validate")] + public async Task Validate([FromBody] ValidateFileDownloadTokenRequest request) + { + var validationError = ValidateBoundary(request.TenantId, request.FileId, request.ObjectKey, request.Method); + if (validationError is not null || string.IsNullOrWhiteSpace(request.Token)) + { + return Ok(new { active = false }); + } + + if (!IsTenantAllowed(request.TenantId)) + { + return Ok(new { active = false }); + } + + var tokenHash = HashToken(request.Token); + var now = DateTimeOffset.UtcNow; + var record = await _dbContext.FileAccessDownloadTokens + .SingleOrDefaultAsync(token => + token.TokenHash == tokenHash + && token.RevokedAt == null + && token.ExpiresAt > now, + HttpContext.RequestAborted); + + if (record is null + || record.TenantId != request.TenantId + || !string.Equals(record.Method, "GET", StringComparison.OrdinalIgnoreCase) + || !string.Equals(record.FileId, NormalizeOptional(request.FileId), StringComparison.Ordinal) + || !string.Equals(record.ObjectKey, NormalizeOptional(request.ObjectKey), StringComparison.Ordinal)) + { + return Ok(new { active = false }); + } + + var user = await _userManager.FindByIdAsync(record.UserId.ToString()); + if (user is null || user.DisabledAt.HasValue) + { + return Ok(new { active = false }); + } + + record.LastValidatedAt = now; + await _dbContext.SaveChangesAsync(HttpContext.RequestAborted); + + return Ok(new + { + active = true, + tenant_id = record.TenantId, + user_id = record.UserId, + file_id = record.FileId, + object_key = record.ObjectKey, + method = record.Method, + scope = record.Scope, + expires_at = record.ExpiresAt + }); + } + + private bool IsTenantAllowed(Guid tenantId) + { + var tokenTenantId = User.FindFirst("tenant_id")?.Value; + return string.IsNullOrWhiteSpace(tokenTenantId) + || (Guid.TryParse(tokenTenantId, out var parsed) && parsed == tenantId); + } + + private static string? ValidateBoundary(Guid tenantId, string? fileId, string? objectKey, string? method) + { + if (tenantId == Guid.Empty) + { + return "tenant_id is required."; + } + + if (!string.Equals(method ?? "GET", "GET", StringComparison.OrdinalIgnoreCase)) + { + return "Only method=GET is supported for file download tokens."; + } + + if (string.IsNullOrWhiteSpace(fileId) && string.IsNullOrWhiteSpace(objectKey)) + { + return "file_id or object_key is required."; + } + + return null; + } + + private static TimeSpan ResolveLifetime(int? expiresInSeconds) + { + if (!expiresInSeconds.HasValue) + { + return DefaultLifetime; + } + + var requested = TimeSpan.FromSeconds(expiresInSeconds.Value); + if (requested < MinimumLifetime) + { + return MinimumLifetime; + } + + return requested > MaximumLifetime ? MaximumLifetime : requested; + } + + private static string? NormalizeOptional(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{TokenPurpose}:{token}")); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index e7b060a..62a0375 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -149,6 +149,8 @@ builder.Services.AddAuthorization(options => 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(options => diff --git a/src/MemberCenter.Domain/Entities/FileAccessDownloadToken.cs b/src/MemberCenter.Domain/Entities/FileAccessDownloadToken.cs new file mode 100644 index 0000000..862a37b --- /dev/null +++ b/src/MemberCenter.Domain/Entities/FileAccessDownloadToken.cs @@ -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; +} diff --git a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs index 9d57ba0..097c8b4 100644 --- a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs +++ b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs @@ -26,6 +26,7 @@ public class MemberCenterDbContext public DbSet AuthResources => Set(); public DbSet AuthResourceScopes => Set(); public DbSet AuthClientUsagePermissions => Set(); + public DbSet FileAccessDownloadTokens => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -245,6 +246,30 @@ public class MemberCenterDbContext entity.HasIndex(x => new { x.Usage, x.Scope }).IsUnique(); }); + builder.Entity(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() + .WithMany() + .HasForeignKey(x => x.TenantId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + builder.Entity(entity => { entity.ToTable("users"); diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260423055404_AddFileAccessDownloadTokens.Designer.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260423055404_AddFileAccessDownloadTokens.Designer.cs new file mode 100644 index 0000000..6a5d6e1 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260423055404_AddFileAccessDownloadTokens.Designer.cs @@ -0,0 +1,1302 @@ +// +using System; +using System.Collections.Generic; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(MemberCenterDbContext))] + [Migration("20260423055404_AddFileAccessDownloadTokens")] + partial class AddFileAccessDownloadTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("ActorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthClientUsagePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Usage") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Usage", "Scope") + .IsUnique(); + + b.ToTable("auth_client_usage_permissions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowDelegatedToken") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Audience") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequireTenant") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Audience") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("auth_resources", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ResourceId") + .HasColumnType("uuid"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Scope") + .HasDatabaseName("idx_auth_resource_scopes_scope"); + + b.HasIndex("ResourceId", "Scope") + .IsUnique(); + + b.ToTable("auth_resource_scopes", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlacklistedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("BlacklistedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("email_blacklist", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_email_verifications_email"); + + b.HasIndex("TenantId"); + + b.ToTable("email_verifications", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IssuedByClientId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastValidatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("GET"); + + b.Property("ObjectKey") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasDefaultValue("files:download.read"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("idx_file_access_download_tokens_expires_at"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId") + .HasDatabaseName("idx_file_access_download_tokens_tenant_user"); + + b.ToTable("file_access_download_tokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("newsletter_lists", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ListId") + .HasColumnType("uuid"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_newsletter_subscriptions_email"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_newsletter_subscriptions_list_id"); + + b.HasIndex("UserId"); + + b.HasIndex("ListId", "Email") + .IsUnique(); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.SystemFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("system_flags", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property>("Domains") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.HasKey("Id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("unsubscribe_tokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressLine2") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressMetaJson") + .HasColumnType("jsonb"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("District") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RecipientName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RecipientPhone") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StateRegion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Usage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("shipping"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .HasDatabaseName("idx_user_addresses_user_id"); + + b.HasIndex("UserId", "IsDefault") + .IsUnique() + .HasDatabaseName("ux_user_addresses_default_per_user") + .HasFilter("\"IsDefault\" = true"); + + b.HasIndex("UserId", "Usage") + .HasDatabaseName("idx_user_addresses_user_id_usage"); + + b.ToTable("user_addresses", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Department") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("unspecified"); + + b.Property("InvoiceTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("JobTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LandlinePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MobilePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NickName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Remark") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TaxId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("UserId"); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("BlacklistedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BlacklistedBy") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlacklisted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => + { + b.HasOne("MemberCenter.Domain.Entities.AuthResource", "Resource") + .WithMany("Scopes") + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resource"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant") + .WithMany("NewsletterLists") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterList", "List") + .WithMany("Subscriptions") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("List"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterSubscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany("Addresses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithOne("Profile") + .HasForeignKey("MemberCenter.Domain.Entities.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => + { + b.Navigation("Scopes"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + b.Navigation("NewsletterLists"); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b => + { + b.Navigation("Addresses"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260423055404_AddFileAccessDownloadTokens.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260423055404_AddFileAccessDownloadTokens.cs new file mode 100644 index 0000000..f976d86 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260423055404_AddFileAccessDownloadTokens.cs @@ -0,0 +1,78 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddFileAccessDownloadTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "file_access_download_tokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "text", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + FileId = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + ObjectKey = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + Method = table.Column(type: "character varying(10)", maxLength: 10, nullable: false, defaultValue: "GET"), + Scope = table.Column(type: "character varying(200)", maxLength: 200, nullable: false, defaultValue: "files:download.read"), + IssuedByClientId = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + LastValidatedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "file_access_download_tokens"); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs index a7a275e..d938bb0 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs @@ -251,6 +251,78 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.ToTable("email_verifications", (string)null); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IssuedByClientId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastValidatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("GET"); + + b.Property("ObjectKey") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasDefaultValue("files:download.read"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("idx_file_access_download_tokens_expires_at"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId") + .HasDatabaseName("idx_file_access_download_tokens_tenant_user"); + + b.ToTable("file_access_download_tokens", (string)null); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => { b.Property("Id") @@ -500,14 +572,14 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.HasIndex("UserId") .HasDatabaseName("idx_user_addresses_user_id"); - b.HasIndex("UserId", "Usage") - .HasDatabaseName("idx_user_addresses_user_id_usage"); - b.HasIndex("UserId", "IsDefault") .IsUnique() .HasDatabaseName("ux_user_addresses_default_per_user") .HasFilter("\"IsDefault\" = true"); + b.HasIndex("UserId", "Usage") + .HasDatabaseName("idx_user_addresses_user_id_usage"); + b.ToTable("user_addresses", (string)null); }); @@ -1042,6 +1114,21 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations .IsRequired(); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => { b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant") diff --git a/src/MemberCenter.TestSite/Controllers/HomeController.cs b/src/MemberCenter.TestSite/Controllers/HomeController.cs index 0674fe8..a7d6e03 100644 --- a/src/MemberCenter.TestSite/Controllers/HomeController.cs +++ b/src/MemberCenter.TestSite/Controllers/HomeController.cs @@ -102,6 +102,32 @@ public class HomeController : Controller return RedirectToAction(nameof(Index)); } + [HttpPost("auth/logout")] + public IActionResult Logout() + { + var options = GetOptions(); + var callbackUrl = Url.ActionLink(nameof(LogoutCallback), "Home"); + HttpContext.Session.Remove(UserAccessTokenKey); + HttpContext.Session.Remove(UserRefreshTokenKey); + + if (string.IsNullOrWhiteSpace(callbackUrl)) + { + StoreResult("Redirect logout", "Unable to build logout callback URL."); + return RedirectToAction(nameof(Index)); + } + + return Redirect($"{TrimSlash(options.WebBaseUrl)}/account/logout?returnUrl={Uri.EscapeDataString(callbackUrl)}"); + } + + [HttpGet("auth/logout-callback")] + public IActionResult LogoutCallback() + { + HttpContext.Session.Remove(UserAccessTokenKey); + HttpContext.Session.Remove(UserRefreshTokenKey); + StoreResult("Redirect logout", "Member Center session cleared and TestSite user tokens removed."); + return RedirectToAction(nameof(Index)); + } + [HttpPost("auth/api-login")] public async Task ApiLogin(string email, string password) { diff --git a/src/MemberCenter.TestSite/Models/MemberCenterTestOptions.cs b/src/MemberCenter.TestSite/Models/MemberCenterTestOptions.cs index 74bd712..1e52947 100644 --- a/src/MemberCenter.TestSite/Models/MemberCenterTestOptions.cs +++ b/src/MemberCenter.TestSite/Models/MemberCenterTestOptions.cs @@ -3,8 +3,10 @@ namespace MemberCenter.TestSite.Models; public sealed class MemberCenterTestOptions { public string ApiBaseUrl { get; set; } = "http://localhost:7850"; + public string WebBaseUrl { get; set; } = "http://localhost:5080"; public string WebLoginClientId { get; set; } = string.Empty; public string WebLoginRedirectPath { get; set; } = "/auth/callback"; + public string WebLogoutRedirectPath { get; set; } = "/auth/logout-callback"; public string WebLoginScopes { get; set; } = "openid email profile profile:basic.read profile:basic.write profile:addresses.read profile:addresses.write profile:subscriptions.read profile:subscriptions.write"; public string ServiceClientId { get; set; } = string.Empty; public string ServiceClientSecret { get; set; } = string.Empty; diff --git a/src/MemberCenter.TestSite/Views/Home/Index.cshtml b/src/MemberCenter.TestSite/Views/Home/Index.cshtml index ac67fb9..d8af594 100644 --- a/src/MemberCenter.TestSite/Views/Home/Index.cshtml +++ b/src/MemberCenter.TestSite/Views/Home/Index.cshtml @@ -23,6 +23,8 @@
API Base URL
@Model.Options.ApiBaseUrl
+
Web Base URL
+
@Model.Options.WebBaseUrl
web_login Client ID
@(string.IsNullOrWhiteSpace(Model.Options.WebLoginClientId) ? "not configured" : Model.Options.WebLoginClientId)
Service Client ID
@@ -40,6 +42,9 @@
+
+ +
diff --git a/src/MemberCenter.TestSite/appsettings.json b/src/MemberCenter.TestSite/appsettings.json index 6fcf7c3..d74445e 100644 --- a/src/MemberCenter.TestSite/appsettings.json +++ b/src/MemberCenter.TestSite/appsettings.json @@ -1,8 +1,10 @@ { "MemberCenter": { "ApiBaseUrl": "http://localhost:7850", + "WebBaseUrl": "http://localhost:5080", "WebLoginClientId": "f48329ef38c54a62b627585a75c9b5d5", "WebLoginRedirectPath": "/auth/callback", + "WebLogoutRedirectPath": "/auth/logout-callback", "WebLoginScopes": "openid email profile profile:basic.read profile:basic.write profile:addresses.read profile:addresses.write profile:subscriptions.read profile:subscriptions.write", "ServiceClientId": "e9fe7ae413c54ae49432eca6474648d3", "ServiceClientSecret": "To/WaVObQgxCaZwzGexfp/pvUwI2G5o1r55v4sRukYw=", diff --git a/src/MemberCenter.Web/Controllers/AccountController.cs b/src/MemberCenter.Web/Controllers/AccountController.cs index bb3fdab..c0b44a1 100644 --- a/src/MemberCenter.Web/Controllers/AccountController.cs +++ b/src/MemberCenter.Web/Controllers/AccountController.cs @@ -150,12 +150,35 @@ public class AccountController : Controller return RedirectToAction("Index", "Home", new { area = string.Empty }); } + [HttpGet] + public async Task Logout(string? returnUrl = null) + { + if (User.Identity?.IsAuthenticated == true) + { + await _signInManager.SignOutAsync(); + } + + if (IsAllowedReturnUrl(returnUrl)) + { + return Redirect(returnUrl!); + } + + return LocalRedirect("~/"); + } + [HttpPost] + [ActionName("Logout")] [Authorize] [ValidateAntiForgeryToken] - public async Task Logout() + public async Task LogoutPost(string? returnUrl = null) { await _signInManager.SignOutAsync(); + + if (IsAllowedReturnUrl(returnUrl)) + { + return Redirect(returnUrl!); + } + return LocalRedirect("~/"); } @@ -372,6 +395,8 @@ public class AccountController : Controller allowedPrefixes.Add(_configuration["Auth:Issuer"]); allowedPrefixes.AddRange((_configuration["Auth:AllowedLoginReturnUrlPrefixes"] ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + allowedPrefixes.AddRange((_configuration["Auth:AllowedLogoutReturnUrlPrefixes"] ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); return allowedPrefixes .Where(prefix => !string.IsNullOrWhiteSpace(prefix))