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.
This commit is contained in:
warrenchen 2026-04-24 16:27:31 +09:00
parent 09589ef631
commit e77fdec76b
21 changed files with 1940 additions and 51 deletions

View File

@ -3,6 +3,7 @@ ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgr
Auth__Issuer=http://localhost:7850/ Auth__Issuer=http://localhost:7850/
Auth__WebLoginUrl=http://localhost:5080/account/login Auth__WebLoginUrl=http://localhost:5080/account/login
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/ Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
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

View File

@ -79,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
@ -196,17 +198,16 @@
- access agent 以 JWKS 驗簽 JWT並驗 `iss/aud/exp/scope/tenant_id` - access agent 以 JWKS 驗簽 JWT並驗 `iss/aud/exp/scope/tenant_id`
2) Download 採 delegated short-lived token 2) Download 採 delegated short-lived token
- client 向 `A service` 要求下載 - client 向 `A service` 要求下載
- `A service` 驗商業規則與檔案權限 - `A service` 以自己的商業邏輯驗證 Member Center user 身份與檔案權限
- `A service` 向新 File Access 專案取得短效 download token或由 File Access 專案提供簽發元件 - `A service` `files:download.delegate` 向 Member Center 申請短效 download token
- client 帶短效 token 直接向 access agent / file space 請求檔案 - client 帶短效 token 直接向 access agent / file space 請求檔案
- access agent 驗 token 後放行 - access agent `files:download.read` 回打 Member Center token 與實際 GET request 邊界一致後放行
規則: 規則:
- 不直接將一般 S2S access token 暴露給 client 作為下載 token - 不直接將一般 S2S access token 暴露給 client 作為下載 token
- download token 應至少帶: - download token 應至少帶:
- `aud=file_access_api`
- `scope=files:download.read`
- `tenant_id` - `tenant_id`
- `user_id`
- `file_id``object_key` - `file_id``object_key`
- `method=GET` - `method=GET`
- 短效 `exp` - 短效 `exp`
@ -216,11 +217,7 @@
- `scope=files:upload.write` - `scope=files:upload.write`
- `tenant_id` - `tenant_id`
- access agent 應驗: - access agent 應驗:
- JWT signatureJWKS - Member Center validation endpoint 回傳 `active=true`
- `iss`
- `aud`
- `exp`
- `scope`
- `tenant_id` - `tenant_id`
- 檔案識別與 method 是否與 token 一致 - 檔案識別與 method 是否與 token 一致
@ -256,7 +253,7 @@ resource registry 至少需定義:
設計原則: 設計原則:
- resource registry 由 DB 與管理 UI 管理非敏感欄位 - resource registry 由 DB 與管理 UI 管理非敏感欄位
- `TokenController` 依 scope 與 usage 對照 resource registry 計算 resources / audiences - `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 即可存取對應能力 - 對外資料讀寫授權完全由 scope 決定,只要 client 被授權該 scope 即可存取對應能力
目前實作狀態: 目前實作狀態:
@ -264,7 +261,7 @@ resource registry 至少需定義:
- 預設 seed 已包含 `member_center_api``send_engine_api``file_access_api` - 預設 seed 已包含 `member_center_api``send_engine_api``file_access_api`
- OAuth client usage-scope matrix 已由 DB 驅動,包含 `file_api` - OAuth client usage-scope matrix 已由 DB 驅動,包含 `file_api`
- resource registry 管理 UI 仍待補 - 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 介面(草案) ## 7. API 介面(草案)
- GET `/oauth/authorize` - GET `/oauth/authorize`
@ -321,7 +318,7 @@ resource registry 至少需定義:
- token 內含 `tenant_id` 與 scope - token 內含 `tenant_id` 與 scope
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWTJWS - Send Engine 收到租戶請求後以 JWKS 驗簽 JWTJWS
- 驗簽通過後將 `tenant_id` 固定在 request context不接受 body 覆寫 - 驗簽通過後將 `tenant_id` 固定在 request context不接受 body 覆寫
- File Access 與未來外部服務亦沿用此模型File Access 的 delegated token 規則由新 File Access 專案處理 - File Access 與未來外部服務亦沿用此模型File Access delegated download token 由 Member Center 簽發與線上驗證
- 若其他服務要讀取會員個資,應只授予必要的 profile read scopes不應沿用過寬的 `profile` OIDC scope - 若其他服務要讀取會員個資,應只授予必要的 profile read scopes不應沿用過寬的 `profile` OIDC scope
- service API 為主要整合模式,存取控制以 scopes 為唯一授權來源 - service API 為主要整合模式,存取控制以 scopes 為唯一授權來源
@ -331,7 +328,7 @@ resource registry 至少需定義:
- `subscription.linked_to_user` 事件已發送 - `subscription.linked_to_user` 事件已發送
- 安全設定頁access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime - 安全設定頁access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足 - 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. 安全與合規 ## 8. 安全與合規
- 密碼強度與防暴力破解rate limit + lockout - 密碼強度與防暴力破解rate limit + lockout

View File

@ -18,6 +18,13 @@
- [UI] 若 Web 與 API 不同 origin需設定 `Auth:WebLoginUrl`,且 Web 端需允許導回 `Auth:Issuer``Auth:AllowedLoginReturnUrlPrefixes` - [UI] 若 Web 與 API 不同 origin需設定 `Auth:WebLoginUrl`,且 Web 端需允許導回 `Auth:Issuer``Auth:AllowedLoginReturnUrlPrefixes`
- [UI] 若 Web 與 API 位於不同子網域,需設定 `Auth:CookieDomain`,讓 authorize endpoint 可讀取 Web login cookie - [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 EngineClient Credentials + JWT 驗簽) ## F-02b 內容站台呼叫 Send EngineClient Credentials + JWT 驗簽)
- [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token`usage=send_api` - [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token`usage=send_api`
- [API] 內容站台帶 Bearer token 呼叫 Send Engine 建立發送任務 - [API] 內容站台帶 Bearer token 呼叫 Send Engine 建立發送任務
@ -33,12 +40,12 @@
## F-02d 檔案下載A service -> client -> File Space ## F-02d 檔案下載A service -> client -> File Space
- [API] client 向 `A service` 請求下載檔案 - [API] client 向 `A service` 請求下載檔案
- [API] `A service` 驗證該 request 是否可讀取指定檔案 - [API] `A service` 以自己的商業邏輯驗證該 user 是否可讀取指定檔案
- [API] `A service` 向新 File Access 專案取得短效 download token或使用 File Access 專案提供的簽發元件 - [API] `A service` `files:download.delegate` 呼叫 Member Center `POST /file-access/download-tokens` 取得短效 download token
- [API] download token 需至少綁定 `tenant_id + file_id/object_key + method=GET + exp` - [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] `A service` 將帶短效 token 的下載 URL 回給 client
- [UI/API] client 直接向 access agent / file space 請求檔案 - [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。 註記:下載流程不直接暴露一般 S2S token 給 client。

View File

@ -163,7 +163,7 @@
- `MemberCenter.Api` 已有 `GET /user/profile`,但目前只提供基礎欄位,不足以支撐其他服務查詢完整會員資料。 - `MemberCenter.Api` 已有 `GET /user/profile`,但目前只提供基礎欄位,不足以支撐其他服務查詢完整會員資料。
- `docs/DESIGN.md` / `docs/FLOWS.md` 已定義 File Access 流程: - `docs/DESIGN.md` / `docs/FLOWS.md` 已定義 File Access 流程:
- upload: `client_credentials` 取得 upload token 後由外部服務直連 file space - upload: `client_credentials` 取得 upload token 後由外部服務直連 file space
- download: 由業務服務驗權後,交由新 File Access 專案簽發或管理短效 delegated download token - download: 由業務服務驗權後,向 Member Center 申請短效 delegated download tokenFile Access agent 回打 Member Center 驗證
- Auth resource registry 第一版已落地: - Auth resource registry 第一版已落地:
- 新增 `auth_resources``auth_resource_scopes``auth_client_usage_permissions` - 新增 `auth_resources``auth_resource_scopes``auth_client_usage_permissions`
- `TokenController` 已改由 scope 查 registry 決定 token audiences - `TokenController` 已改由 scope 查 registry 決定 token audiences
@ -177,7 +177,7 @@
- profile / addresses / subscriptions 畫面與驗證目前為最小可用版本,尚未完成 UI refinement。 - 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 管理畫面。 - Token resource / audience 已抽象為 registry後續需補 resource registry 管理畫面。
- Email 樣板正式文案與會員 / 後台 UI 細節仍待整理。 - Email 樣板正式文案與會員 / 後台 UI 細節仍待整理。
- rate limit 仍缺少 `one-click unsubscribe token` 與更細的風控觀測。 - rate limit 仍缺少 `one-click unsubscribe token` 與更細的風控觀測。
@ -628,9 +628,10 @@
- `files:metadata.read` - `files:metadata.read`
- 新增 audience - 新增 audience
- `file_access_api` - `file_access_api`
- File Access 新專案需實作 delegated token 規則: - Member Center 需實作 delegated download token 規則:
- 下載 token 必須短效 - 下載 token 必須短效
- 必須綁定 `tenant_id` - 必須綁定 `tenant_id`
- 必須綁定 `user_id`
- 必須綁定 `file_id``object_key` - 必須綁定 `file_id``object_key`
- 必須綁定 `method` - 必須綁定 `method`
- 不可直接重用一般 S2S access token 給 client - 不可直接重用一般 S2S access token 給 client
@ -643,7 +644,7 @@
- scopes - scopes
- client usages - client usages
- 是否需要 `tenant_id` - 是否需要 `tenant_id`
- 是否允許 delegated tokenFile Access 判斷,不代表 Member Center 負責簽發檔案下載 token - 是否允許 delegated tokenMember Center 判斷該 resource 是否可簽發短效下載 token
- 新增資源服務時,只擴充 registry 與授權規則,不再修改硬編碼 audience 分支。 - 新增資源服務時,只擴充 registry 與授權規則,不再修改硬編碼 audience 分支。
- 所有外部資料存取均以 scope 作為唯一授權依據。 - 所有外部資料存取均以 scope 作為唯一授權依據。
@ -654,13 +655,13 @@
- `file_api` -> `file_access_api` - `file_api` -> `file_access_api`
- scope 用於細粒度權限控制。 - scope 用於細粒度權限控制。
- `TokenController` 依 scope 與 usage 對照 resource registry 計算 `resources/audiences` - `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 - Send Engine、Member Center profile/newsletter scopes、File Access scopes 已進 registry
- `TokenController` 已以 registry 解析 audiences - `TokenController` 已以 registry 解析 audiences
- OAuth client usage-scope matrix 已以 `auth_client_usage_permissions` 驅動 - 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 CenterFile Access agent 尚需串接 validation endpoint
### 9. 審計紀錄 ### 9. 審計紀錄
狀態:`大致完成,少數治理事件待續作` 狀態:`大致完成,少數治理事件待續作`
@ -768,13 +769,13 @@
### API ### API
- 若設定畫面走 API需新增設定讀寫端點。 - 若設定畫面走 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。 - 需新增 current-user 型 profile / addresses / subscriptions API 與對應 scopes。
### Infrastructure ### Infrastructure
- 新增 SMTP sender 與設定存取服務。 - 新增 SMTP sender 與設定存取服務。
- 調整帳號 provisioning 與角色管理服務。 - 調整帳號 provisioning 與角色管理服務。
- token resource resolver 已抽到 registry serviceFile Access delegated token issuer 不屬於 Member Center。 - token resource resolver 已抽到 registry serviceFile Access delegated token issuer / validator 已落在 Member Center。
- 新增會員基本資料與地址簿的資料模型與服務層。 - 新增會員基本資料與地址簿的資料模型與服務層。
### Installer ### Installer

View File

@ -162,28 +162,26 @@
- access agent 驗 `iss/aud/exp/scope/tenant_id` - access agent 驗 `iss/aud/exp/scope/tenant_id`
- Download 採 delegated short-lived token - Download 採 delegated short-lived token
- 不直接將一般 S2S token 暴露給 client - 不直接將一般 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 至少需帶: - 下載 token 至少需帶:
- `aud=file_access_api`
- `scope=files:download.read`
- `tenant_id` - `tenant_id`
- `user_id`
- `file_id``object_key` - `file_id``object_key`
- `method=GET` - `method=GET`
- 短效 `exp` - 短效 `exp`
- 建議 `jti` - 建議 `jti`
- access agent 至少應驗: - access agent 至少應送回 Member Center 驗:
- JWT signatureJWKS - opaque download token
- `iss`
- `aud`
- `exp`
- `scope`
- `tenant_id` - `tenant_id`
- token 內檔案識別與實際 request 是否一致 - token 內檔案識別與實際 request 是否一致
- token 內 method 與實際 request 是否一致 - token 內 method 與實際 request 是否一致
補充: 補充:
- File Access 與 Send Engine 同屬「外部資源服務」,驗證模型一致 - File Access 與 Send Engine 同屬「外部資源服務」,驗證模型一致
- 差異在於 File Access download token 為 delegated short-lived token而非一般 client credentials token - 差異在於 File Access download token 為 Member Center 簽發的 delegated short-lived opaque token而非一般 client credentials token
- delegated download token issuing、流程測試與 sample code 會在新 File Access 專案實作,不屬於 Member Center API - Member Center 負責 delegated download token issuing 與 validationFile Access agent 負責把實際 GET request 的 tenant/file/method 邊界帶回 Member Center 驗證
### 回寫原因碼Send Engine -> Member Center ### 回寫原因碼Send Engine -> Member Center
- `hard_bounce` - `hard_bounce`

View File

@ -22,6 +22,7 @@ http://localhost:5243
{ {
"MemberCenter": { "MemberCenter": {
"ApiBaseUrl": "http://localhost:7850", "ApiBaseUrl": "http://localhost:7850",
"WebBaseUrl": "http://localhost:5080",
"WebLoginClientId": "<web_login client id>", "WebLoginClientId": "<web_login client id>",
"ServiceClientId": "<service client id>", "ServiceClientId": "<service client id>",
"ServiceClientSecret": "<service client secret>" "ServiceClientSecret": "<service client secret>"
@ -45,6 +46,7 @@ service OAuth client
```text ```text
Auth__WebLoginUrl=<Member Center Web login URL> Auth__WebLoginUrl=<Member Center Web login URL>
Auth__AllowedLoginReturnUrlPrefixes=<Member Center API issuer/base URL> Auth__AllowedLoginReturnUrlPrefixes=<Member Center API issuer/base URL>
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__CookieDomain=<shared cookie domain, production subdomain SSO only> Auth__CookieDomain=<shared cookie domain, production subdomain SSO only>
``` ```
@ -53,14 +55,15 @@ Auth__CookieDomain=<shared cookie domain, production subdomain SSO only>
測試站目前包含: 測試站目前包含:
1. Redirect login 拿 token 1. Redirect login 拿 token
2. API login 拿 token 2. Redirect logout 清除 Member Center Web session 並回到 TestSite
3. `GET /user/profile` 3. API login 拿 token
4. `POST /user/profile` 4. `GET /user/profile`
5. `GET /user/addresses` 5. `POST /user/profile`
6. `POST /user/addresses` 6. `GET /user/addresses`
7. `GET /user/subscriptions` 7. `POST /user/addresses`
8. `POST /user/subscriptions/{id}/unsubscribe` 8. `GET /user/subscriptions`
9. service token 呼叫 `GET /user/profile/by-email` 9. `POST /user/subscriptions/{id}/unsubscribe`
10. service token 呼叫 `GET /user/addresses/by-email` 10. service token 呼叫 `GET /user/profile/by-email`
11. service token 呼叫 `GET /user/addresses/by-email`
測試站只做 happy path不取代完整自動化測試。 測試站只做 happy path不取代完整自動化測試。

View File

@ -43,7 +43,7 @@
### 會員端(統一 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-04.1 已登入修改密碼: `/account/changepassword`
- UC-05 Email 驗證: `/account/verifyemail?email=...&token=...` - UC-05 Email 驗證: `/account/verifyemail?email=...&token=...`

View File

@ -138,7 +138,7 @@ paths:
/auth/logout: /auth/logout:
post: post:
summary: Logout (revoke refresh token) summary: Logout current authenticated session
security: security:
- BearerAuth: [] - BearerAuth: []
requestBody: requestBody:
@ -810,6 +810,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:
@ -927,6 +969,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:

View File

@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace MemberCenter.Api.Contracts;
public sealed record IssueFileDownloadTokenRequest(
[property: JsonPropertyName("tenant_id")] Guid TenantId,
[property: JsonPropertyName("user_id")] Guid UserId,
[property: JsonPropertyName("file_id")] string? FileId,
[property: JsonPropertyName("object_key")] string? ObjectKey,
[property: JsonPropertyName("method")] string? Method,
[property: JsonPropertyName("expires_in_seconds")] int? ExpiresInSeconds);
public sealed record ValidateFileDownloadTokenRequest(
[property: JsonPropertyName("token")] string Token,
[property: JsonPropertyName("tenant_id")] Guid TenantId,
[property: JsonPropertyName("file_id")] string? FileId,
[property: JsonPropertyName("object_key")] string? ObjectKey,
[property: JsonPropertyName("method")] string? Method);

View File

@ -0,0 +1,204 @@
using System.Security.Cryptography;
using System.Text;
using MemberCenter.Api.Contracts;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
namespace MemberCenter.Api.Controllers;
[ApiController]
[Route("file-access/download-tokens")]
public sealed class FileAccessController : ControllerBase
{
private const string DownloadScope = "files:download.read";
private const string TokenPurpose = "file_access_download";
private static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(5);
private static readonly TimeSpan MinimumLifetime = TimeSpan.FromSeconds(30);
private static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(15);
private readonly MemberCenterDbContext _dbContext;
private readonly UserManager<ApplicationUser> _userManager;
public FileAccessController(MemberCenterDbContext dbContext, UserManager<ApplicationUser> userManager)
{
_dbContext = dbContext;
_userManager = userManager;
}
[Authorize(Policy = "FilesDownloadDelegate")]
[HttpPost("")]
public async Task<IActionResult> Issue([FromBody] IssueFileDownloadTokenRequest request)
{
var validationError = ValidateBoundary(request.TenantId, request.FileId, request.ObjectKey, request.Method);
if (validationError is not null)
{
return BadRequest(validationError);
}
if (!IsTenantAllowed(request.TenantId))
{
return BadRequest("tenant_id does not match token tenant scope.");
}
if (request.UserId == Guid.Empty)
{
return BadRequest("user_id is required.");
}
var user = await _userManager.FindByIdAsync(request.UserId.ToString());
if (user is null || user.DisabledAt.HasValue)
{
return BadRequest("user_id is invalid or disabled.");
}
var lifetime = ResolveLifetime(request.ExpiresInSeconds);
var expiresAt = DateTimeOffset.UtcNow.Add(lifetime);
var token = $"fdt_{WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32))}";
var record = new FileAccessDownloadToken
{
Id = Guid.NewGuid(),
TokenHash = HashToken(token),
TenantId = request.TenantId,
UserId = request.UserId,
FileId = NormalizeOptional(request.FileId),
ObjectKey = NormalizeOptional(request.ObjectKey),
Method = "GET",
Scope = DownloadScope,
IssuedByClientId = User.GetClaim(OpenIddictConstants.Claims.ClientId),
ExpiresAt = expiresAt
};
_dbContext.FileAccessDownloadTokens.Add(record);
await _dbContext.SaveChangesAsync(HttpContext.RequestAborted);
return Ok(new
{
token,
token_type = "file_download",
expires_at = expiresAt,
tenant_id = record.TenantId,
user_id = record.UserId,
file_id = record.FileId,
object_key = record.ObjectKey,
method = record.Method,
scope = record.Scope
});
}
[Authorize(Policy = "FilesDownloadRead")]
[HttpPost("validate")]
public async Task<IActionResult> Validate([FromBody] ValidateFileDownloadTokenRequest request)
{
var validationError = ValidateBoundary(request.TenantId, request.FileId, request.ObjectKey, request.Method);
if (validationError is not null || string.IsNullOrWhiteSpace(request.Token))
{
return Ok(new { active = false });
}
if (!IsTenantAllowed(request.TenantId))
{
return Ok(new { active = false });
}
var tokenHash = HashToken(request.Token);
var now = DateTimeOffset.UtcNow;
var record = await _dbContext.FileAccessDownloadTokens
.SingleOrDefaultAsync(token =>
token.TokenHash == tokenHash
&& token.RevokedAt == null
&& token.ExpiresAt > now,
HttpContext.RequestAborted);
if (record is null
|| record.TenantId != request.TenantId
|| !string.Equals(record.Method, "GET", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(record.FileId, NormalizeOptional(request.FileId), StringComparison.Ordinal)
|| !string.Equals(record.ObjectKey, NormalizeOptional(request.ObjectKey), StringComparison.Ordinal))
{
return Ok(new { active = false });
}
var user = await _userManager.FindByIdAsync(record.UserId.ToString());
if (user is null || user.DisabledAt.HasValue)
{
return Ok(new { active = false });
}
record.LastValidatedAt = now;
await _dbContext.SaveChangesAsync(HttpContext.RequestAborted);
return Ok(new
{
active = true,
tenant_id = record.TenantId,
user_id = record.UserId,
file_id = record.FileId,
object_key = record.ObjectKey,
method = record.Method,
scope = record.Scope,
expires_at = record.ExpiresAt
});
}
private bool IsTenantAllowed(Guid tenantId)
{
var tokenTenantId = User.FindFirst("tenant_id")?.Value;
return string.IsNullOrWhiteSpace(tokenTenantId)
|| (Guid.TryParse(tokenTenantId, out var parsed) && parsed == tenantId);
}
private static string? ValidateBoundary(Guid tenantId, string? fileId, string? objectKey, string? method)
{
if (tenantId == Guid.Empty)
{
return "tenant_id is required.";
}
if (!string.Equals(method ?? "GET", "GET", StringComparison.OrdinalIgnoreCase))
{
return "Only method=GET is supported for file download tokens.";
}
if (string.IsNullOrWhiteSpace(fileId) && string.IsNullOrWhiteSpace(objectKey))
{
return "file_id or object_key is required.";
}
return null;
}
private static TimeSpan ResolveLifetime(int? expiresInSeconds)
{
if (!expiresInSeconds.HasValue)
{
return DefaultLifetime;
}
var requested = TimeSpan.FromSeconds(expiresInSeconds.Value);
if (requested < MinimumLifetime)
{
return MinimumLifetime;
}
return requested > MaximumLifetime ? MaximumLifetime : requested;
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static string HashToken(string token)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{TokenPurpose}:{token}"));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}

View File

@ -149,6 +149,8 @@ builder.Services.AddAuthorization(options =>
options.AddPolicy("ProfileAddressesWrite", policy => policy.RequireAssertion(context => context.User.HasScope("profile:addresses.write"))); 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("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("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 =>

View File

@ -0,0 +1,18 @@
namespace MemberCenter.Domain.Entities;
public sealed class FileAccessDownloadToken
{
public Guid Id { get; set; }
public string TokenHash { get; set; } = string.Empty;
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public string? FileId { get; set; }
public string? ObjectKey { get; set; }
public string Method { get; set; } = "GET";
public string Scope { get; set; } = "files:download.read";
public string? IssuedByClientId { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public DateTimeOffset? LastValidatedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -26,6 +26,7 @@ public class MemberCenterDbContext
public DbSet<AuthResource> AuthResources => Set<AuthResource>(); public DbSet<AuthResource> AuthResources => Set<AuthResource>();
public DbSet<AuthResourceScope> AuthResourceScopes => Set<AuthResourceScope>(); public DbSet<AuthResourceScope> AuthResourceScopes => Set<AuthResourceScope>();
public DbSet<AuthClientUsagePermission> AuthClientUsagePermissions => Set<AuthClientUsagePermission>(); public DbSet<AuthClientUsagePermission> AuthClientUsagePermissions => Set<AuthClientUsagePermission>();
public DbSet<FileAccessDownloadToken> FileAccessDownloadTokens => Set<FileAccessDownloadToken>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@ -245,6 +246,30 @@ public class MemberCenterDbContext
entity.HasIndex(x => new { x.Usage, x.Scope }).IsUnique(); entity.HasIndex(x => new { x.Usage, x.Scope }).IsUnique();
}); });
builder.Entity<FileAccessDownloadToken>(entity =>
{
entity.ToTable("file_access_download_tokens");
entity.HasKey(x => x.Id);
entity.Property(x => x.TokenHash).IsRequired();
entity.Property(x => x.FileId).HasMaxLength(200);
entity.Property(x => x.ObjectKey).HasMaxLength(1000);
entity.Property(x => x.Method).IsRequired().HasMaxLength(10).HasDefaultValue("GET");
entity.Property(x => x.Scope).IsRequired().HasMaxLength(200).HasDefaultValue("files:download.read");
entity.Property(x => x.IssuedByClientId).HasMaxLength(200);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
entity.HasIndex(x => x.TokenHash).IsUnique();
entity.HasIndex(x => x.ExpiresAt).HasDatabaseName("idx_file_access_download_tokens_expires_at");
entity.HasIndex(x => new { x.TenantId, x.UserId }).HasDatabaseName("idx_file_access_download_tokens_tenant_user");
entity.HasOne<Tenant>()
.WithMany()
.HasForeignKey(x => x.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<ApplicationUser>(entity => builder.Entity<ApplicationUser>(entity =>
{ {
entity.ToTable("users"); entity.ToTable("users");

View File

@ -0,0 +1,78 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddFileAccessDownloadTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "file_access_download_tokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "text", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
FileId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
ObjectKey = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
Method = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false, defaultValue: "GET"),
Scope = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false, defaultValue: "files:download.read"),
IssuedByClientId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LastValidatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_file_access_download_tokens", x => x.Id);
table.ForeignKey(
name: "FK_file_access_download_tokens_tenants_TenantId",
column: x => x.TenantId,
principalTable: "tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_file_access_download_tokens_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_file_access_download_tokens_expires_at",
table: "file_access_download_tokens",
column: "ExpiresAt");
migrationBuilder.CreateIndex(
name: "idx_file_access_download_tokens_tenant_user",
table: "file_access_download_tokens",
columns: new[] { "TenantId", "UserId" });
migrationBuilder.CreateIndex(
name: "IX_file_access_download_tokens_TokenHash",
table: "file_access_download_tokens",
column: "TokenHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_file_access_download_tokens_UserId",
table: "file_access_download_tokens",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "file_access_download_tokens");
}
}
}

View File

@ -251,6 +251,78 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.ToTable("email_verifications", (string)null); b.ToTable("email_verifications", (string)null);
}); });
modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileId")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("IssuedByClientId")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset?>("LastValidatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Method")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasDefaultValue("GET");
b.Property<string>("ObjectKey")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Scope")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasDefaultValue("files:download.read");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ExpiresAt")
.HasDatabaseName("idx_file_access_download_tokens_expires_at");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.HasIndex("TenantId", "UserId")
.HasDatabaseName("idx_file_access_download_tokens_tenant_user");
b.ToTable("file_access_download_tokens", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -500,14 +572,14 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.HasIndex("UserId") b.HasIndex("UserId")
.HasDatabaseName("idx_user_addresses_user_id"); .HasDatabaseName("idx_user_addresses_user_id");
b.HasIndex("UserId", "Usage")
.HasDatabaseName("idx_user_addresses_user_id_usage");
b.HasIndex("UserId", "IsDefault") b.HasIndex("UserId", "IsDefault")
.IsUnique() .IsUnique()
.HasDatabaseName("ux_user_addresses_default_per_user") .HasDatabaseName("ux_user_addresses_default_per_user")
.HasFilter("\"IsDefault\" = true"); .HasFilter("\"IsDefault\" = true");
b.HasIndex("UserId", "Usage")
.HasDatabaseName("idx_user_addresses_user_id_usage");
b.ToTable("user_addresses", (string)null); b.ToTable("user_addresses", (string)null);
}); });
@ -1042,6 +1114,21 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
.IsRequired(); .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 => modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
{ {
b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant") b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant")

View File

@ -102,6 +102,32 @@ public class HomeController : Controller
return RedirectToAction(nameof(Index)); 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")] [HttpPost("auth/api-login")]
public async Task<IActionResult> ApiLogin(string email, string password) public async Task<IActionResult> ApiLogin(string email, string password)
{ {

View File

@ -3,8 +3,10 @@ namespace MemberCenter.TestSite.Models;
public sealed class MemberCenterTestOptions public sealed class MemberCenterTestOptions
{ {
public string ApiBaseUrl { get; set; } = "http://localhost:7850"; 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 WebLoginClientId { get; set; } = string.Empty;
public string WebLoginRedirectPath { get; set; } = "/auth/callback"; 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 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 ServiceClientId { get; set; } = string.Empty;
public string ServiceClientSecret { get; set; } = string.Empty; public string ServiceClientSecret { get; set; } = string.Empty;

View File

@ -23,6 +23,8 @@
<dl> <dl>
<dt>API Base URL</dt> <dt>API Base URL</dt>
<dd>@Model.Options.ApiBaseUrl</dd> <dd>@Model.Options.ApiBaseUrl</dd>
<dt>Web Base URL</dt>
<dd>@Model.Options.WebBaseUrl</dd>
<dt>web_login Client ID</dt> <dt>web_login Client ID</dt>
<dd>@(string.IsNullOrWhiteSpace(Model.Options.WebLoginClientId) ? "not configured" : Model.Options.WebLoginClientId)</dd> <dd>@(string.IsNullOrWhiteSpace(Model.Options.WebLoginClientId) ? "not configured" : Model.Options.WebLoginClientId)</dd>
<dt>Service Client ID</dt> <dt>Service Client ID</dt>
@ -40,6 +42,9 @@
<form method="post" asp-action="RedirectLogin"> <form method="post" asp-action="RedirectLogin">
<button type="submit">Start Redirect Login</button> <button type="submit">Start Redirect Login</button>
</form> </form>
<form method="post" asp-action="Logout">
<button type="submit">Direct Logout</button>
</form>
</article> </article>
<article class="card"> <article class="card">

View File

@ -1,8 +1,10 @@
{ {
"MemberCenter": { "MemberCenter": {
"ApiBaseUrl": "http://localhost:7850", "ApiBaseUrl": "http://localhost:7850",
"WebBaseUrl": "http://localhost:5080",
"WebLoginClientId": "f48329ef38c54a62b627585a75c9b5d5", "WebLoginClientId": "f48329ef38c54a62b627585a75c9b5d5",
"WebLoginRedirectPath": "/auth/callback", "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", "WebLoginScopes": "openid email profile profile:basic.read profile:basic.write profile:addresses.read profile:addresses.write profile:subscriptions.read profile:subscriptions.write",
"ServiceClientId": "e9fe7ae413c54ae49432eca6474648d3", "ServiceClientId": "e9fe7ae413c54ae49432eca6474648d3",
"ServiceClientSecret": "To/WaVObQgxCaZwzGexfp/pvUwI2G5o1r55v4sRukYw=", "ServiceClientSecret": "To/WaVObQgxCaZwzGexfp/pvUwI2G5o1r55v4sRukYw=",

View File

@ -150,12 +150,35 @@ public class AccountController : Controller
return RedirectToAction("Index", "Home", new { area = string.Empty }); return RedirectToAction("Index", "Home", new { area = string.Empty });
} }
[HttpPost] [HttpGet]
[Authorize] public async Task<IActionResult> Logout(string? returnUrl = null)
[ValidateAntiForgeryToken] {
public async Task<IActionResult> Logout() if (User.Identity?.IsAuthenticated == true)
{ {
await _signInManager.SignOutAsync(); await _signInManager.SignOutAsync();
}
if (IsAllowedReturnUrl(returnUrl))
{
return Redirect(returnUrl!);
}
return LocalRedirect("~/");
}
[HttpPost]
[ActionName("Logout")]
[Authorize]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LogoutPost(string? returnUrl = null)
{
await _signInManager.SignOutAsync();
if (IsAllowedReturnUrl(returnUrl))
{
return Redirect(returnUrl!);
}
return LocalRedirect("~/"); return LocalRedirect("~/");
} }
@ -372,6 +395,8 @@ public class AccountController : Controller
allowedPrefixes.Add(_configuration["Auth:Issuer"]); allowedPrefixes.Add(_configuration["Auth:Issuer"]);
allowedPrefixes.AddRange((_configuration["Auth:AllowedLoginReturnUrlPrefixes"] ?? string.Empty) allowedPrefixes.AddRange((_configuration["Auth:AllowedLoginReturnUrlPrefixes"] ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
allowedPrefixes.AddRange((_configuration["Auth:AllowedLogoutReturnUrlPrefixes"] ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
return allowedPrefixes return allowedPrefixes
.Where(prefix => !string.IsNullOrWhiteSpace(prefix)) .Where(prefix => !string.IsNullOrWhiteSpace(prefix))