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:
parent
09589ef631
commit
e77fdec76b
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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。
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -22,6 +22,7 @@ http://localhost:5243
|
||||
{
|
||||
"MemberCenter": {
|
||||
"ApiBaseUrl": "http://localhost:7850",
|
||||
"WebBaseUrl": "http://localhost:5080",
|
||||
"WebLoginClientId": "<web_login client id>",
|
||||
"ServiceClientId": "<service client id>",
|
||||
"ServiceClientSecret": "<service client secret>"
|
||||
@ -45,6 +46,7 @@ service OAuth client:
|
||||
```text
|
||||
Auth__WebLoginUrl=<Member Center Web login URL>
|
||||
Auth__AllowedLoginReturnUrlPrefixes=<Member Center API issuer/base URL>
|
||||
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
|
||||
Auth__CookieDomain=<shared cookie domain, production subdomain SSO only>
|
||||
```
|
||||
|
||||
@ -53,14 +55,15 @@ Auth__CookieDomain=<shared cookie domain, production subdomain SSO only>
|
||||
測試站目前包含:
|
||||
|
||||
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,不取代完整自動化測試。
|
||||
|
||||
@ -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=...`
|
||||
|
||||
@ -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:
|
||||
|
||||
18
src/MemberCenter.Api/Contracts/FileAccessRequests.cs
Normal file
18
src/MemberCenter.Api/Contracts/FileAccessRequests.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MemberCenter.Api.Contracts;
|
||||
|
||||
public sealed record IssueFileDownloadTokenRequest(
|
||||
[property: JsonPropertyName("tenant_id")] Guid TenantId,
|
||||
[property: JsonPropertyName("user_id")] Guid UserId,
|
||||
[property: JsonPropertyName("file_id")] string? FileId,
|
||||
[property: JsonPropertyName("object_key")] string? ObjectKey,
|
||||
[property: JsonPropertyName("method")] string? Method,
|
||||
[property: JsonPropertyName("expires_in_seconds")] int? ExpiresInSeconds);
|
||||
|
||||
public sealed record ValidateFileDownloadTokenRequest(
|
||||
[property: JsonPropertyName("token")] string Token,
|
||||
[property: JsonPropertyName("tenant_id")] Guid TenantId,
|
||||
[property: JsonPropertyName("file_id")] string? FileId,
|
||||
[property: JsonPropertyName("object_key")] string? ObjectKey,
|
||||
[property: JsonPropertyName("method")] string? Method);
|
||||
204
src/MemberCenter.Api/Controllers/FileAccessController.cs
Normal file
204
src/MemberCenter.Api/Controllers/FileAccessController.cs
Normal file
@ -0,0 +1,204 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Domain.Entities;
|
||||
using MemberCenter.Infrastructure.Identity;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace MemberCenter.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("file-access/download-tokens")]
|
||||
public sealed class FileAccessController : ControllerBase
|
||||
{
|
||||
private const string DownloadScope = "files:download.read";
|
||||
private const string TokenPurpose = "file_access_download";
|
||||
private static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan MinimumLifetime = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly MemberCenterDbContext _dbContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public FileAccessController(MemberCenterDbContext dbContext, UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
[Authorize(Policy = "FilesDownloadDelegate")]
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> Issue([FromBody] IssueFileDownloadTokenRequest request)
|
||||
{
|
||||
var validationError = ValidateBoundary(request.TenantId, request.FileId, request.ObjectKey, request.Method);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return BadRequest(validationError);
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(request.TenantId))
|
||||
{
|
||||
return BadRequest("tenant_id does not match token tenant scope.");
|
||||
}
|
||||
|
||||
if (request.UserId == Guid.Empty)
|
||||
{
|
||||
return BadRequest("user_id is required.");
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByIdAsync(request.UserId.ToString());
|
||||
if (user is null || user.DisabledAt.HasValue)
|
||||
{
|
||||
return BadRequest("user_id is invalid or disabled.");
|
||||
}
|
||||
|
||||
var lifetime = ResolveLifetime(request.ExpiresInSeconds);
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(lifetime);
|
||||
var token = $"fdt_{WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32))}";
|
||||
|
||||
var record = new FileAccessDownloadToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TokenHash = HashToken(token),
|
||||
TenantId = request.TenantId,
|
||||
UserId = request.UserId,
|
||||
FileId = NormalizeOptional(request.FileId),
|
||||
ObjectKey = NormalizeOptional(request.ObjectKey),
|
||||
Method = "GET",
|
||||
Scope = DownloadScope,
|
||||
IssuedByClientId = User.GetClaim(OpenIddictConstants.Claims.ClientId),
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
|
||||
_dbContext.FileAccessDownloadTokens.Add(record);
|
||||
await _dbContext.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
token,
|
||||
token_type = "file_download",
|
||||
expires_at = expiresAt,
|
||||
tenant_id = record.TenantId,
|
||||
user_id = record.UserId,
|
||||
file_id = record.FileId,
|
||||
object_key = record.ObjectKey,
|
||||
method = record.Method,
|
||||
scope = record.Scope
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize(Policy = "FilesDownloadRead")]
|
||||
[HttpPost("validate")]
|
||||
public async Task<IActionResult> Validate([FromBody] ValidateFileDownloadTokenRequest request)
|
||||
{
|
||||
var validationError = ValidateBoundary(request.TenantId, request.FileId, request.ObjectKey, request.Method);
|
||||
if (validationError is not null || string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
return Ok(new { active = false });
|
||||
}
|
||||
|
||||
if (!IsTenantAllowed(request.TenantId))
|
||||
{
|
||||
return Ok(new { active = false });
|
||||
}
|
||||
|
||||
var tokenHash = HashToken(request.Token);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = await _dbContext.FileAccessDownloadTokens
|
||||
.SingleOrDefaultAsync(token =>
|
||||
token.TokenHash == tokenHash
|
||||
&& token.RevokedAt == null
|
||||
&& token.ExpiresAt > now,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
if (record is null
|
||||
|| record.TenantId != request.TenantId
|
||||
|| !string.Equals(record.Method, "GET", StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.Equals(record.FileId, NormalizeOptional(request.FileId), StringComparison.Ordinal)
|
||||
|| !string.Equals(record.ObjectKey, NormalizeOptional(request.ObjectKey), StringComparison.Ordinal))
|
||||
{
|
||||
return Ok(new { active = false });
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByIdAsync(record.UserId.ToString());
|
||||
if (user is null || user.DisabledAt.HasValue)
|
||||
{
|
||||
return Ok(new { active = false });
|
||||
}
|
||||
|
||||
record.LastValidatedAt = now;
|
||||
await _dbContext.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
active = true,
|
||||
tenant_id = record.TenantId,
|
||||
user_id = record.UserId,
|
||||
file_id = record.FileId,
|
||||
object_key = record.ObjectKey,
|
||||
method = record.Method,
|
||||
scope = record.Scope,
|
||||
expires_at = record.ExpiresAt
|
||||
});
|
||||
}
|
||||
|
||||
private bool IsTenantAllowed(Guid tenantId)
|
||||
{
|
||||
var tokenTenantId = User.FindFirst("tenant_id")?.Value;
|
||||
return string.IsNullOrWhiteSpace(tokenTenantId)
|
||||
|| (Guid.TryParse(tokenTenantId, out var parsed) && parsed == tenantId);
|
||||
}
|
||||
|
||||
private static string? ValidateBoundary(Guid tenantId, string? fileId, string? objectKey, string? method)
|
||||
{
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
return "tenant_id is required.";
|
||||
}
|
||||
|
||||
if (!string.Equals(method ?? "GET", "GET", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Only method=GET is supported for file download tokens.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fileId) && string.IsNullOrWhiteSpace(objectKey))
|
||||
{
|
||||
return "file_id or object_key is required.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TimeSpan ResolveLifetime(int? expiresInSeconds)
|
||||
{
|
||||
if (!expiresInSeconds.HasValue)
|
||||
{
|
||||
return DefaultLifetime;
|
||||
}
|
||||
|
||||
var requested = TimeSpan.FromSeconds(expiresInSeconds.Value);
|
||||
if (requested < MinimumLifetime)
|
||||
{
|
||||
return MinimumLifetime;
|
||||
}
|
||||
|
||||
return requested > MaximumLifetime ? MaximumLifetime : requested;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string HashToken(string token)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{TokenPurpose}:{token}"));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@ -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<ForwardedHeadersOptions>(options =>
|
||||
|
||||
18
src/MemberCenter.Domain/Entities/FileAccessDownloadToken.cs
Normal file
18
src/MemberCenter.Domain/Entities/FileAccessDownloadToken.cs
Normal file
@ -0,0 +1,18 @@
|
||||
namespace MemberCenter.Domain.Entities;
|
||||
|
||||
public sealed class FileAccessDownloadToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TokenHash { get; set; } = string.Empty;
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public string? FileId { get; set; }
|
||||
public string? ObjectKey { get; set; }
|
||||
public string Method { get; set; } = "GET";
|
||||
public string Scope { get; set; } = "files:download.read";
|
||||
public string? IssuedByClientId { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public DateTimeOffset? LastValidatedAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@ -26,6 +26,7 @@ public class MemberCenterDbContext
|
||||
public DbSet<AuthResource> AuthResources => Set<AuthResource>();
|
||||
public DbSet<AuthResourceScope> AuthResourceScopes => Set<AuthResourceScope>();
|
||||
public DbSet<AuthClientUsagePermission> AuthClientUsagePermissions => Set<AuthClientUsagePermission>();
|
||||
public DbSet<FileAccessDownloadToken> FileAccessDownloadTokens => Set<FileAccessDownloadToken>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
@ -245,6 +246,30 @@ public class MemberCenterDbContext
|
||||
entity.HasIndex(x => new { x.Usage, x.Scope }).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<FileAccessDownloadToken>(entity =>
|
||||
{
|
||||
entity.ToTable("file_access_download_tokens");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.TokenHash).IsRequired();
|
||||
entity.Property(x => x.FileId).HasMaxLength(200);
|
||||
entity.Property(x => x.ObjectKey).HasMaxLength(1000);
|
||||
entity.Property(x => x.Method).IsRequired().HasMaxLength(10).HasDefaultValue("GET");
|
||||
entity.Property(x => x.Scope).IsRequired().HasMaxLength(200).HasDefaultValue("files:download.read");
|
||||
entity.Property(x => x.IssuedByClientId).HasMaxLength(200);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
|
||||
entity.HasIndex(x => x.TokenHash).IsUnique();
|
||||
entity.HasIndex(x => x.ExpiresAt).HasDatabaseName("idx_file_access_download_tokens_expires_at");
|
||||
entity.HasIndex(x => new { x.TenantId, x.UserId }).HasDatabaseName("idx_file_access_download_tokens_tenant_user");
|
||||
entity.HasOne<Tenant>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne<ApplicationUser>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<ApplicationUser>(entity =>
|
||||
{
|
||||
entity.ToTable("users");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -251,6 +251,78 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("email_verifications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("IssuedByClientId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastValidatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Method")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasDefaultValue("GET");
|
||||
|
||||
b.Property<string>("ObjectKey")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasDefaultValue("files:download.read");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt")
|
||||
.HasDatabaseName("idx_file_access_download_tokens_expires_at");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("TenantId", "UserId")
|
||||
.HasDatabaseName("idx_file_access_download_tokens_tenant_user");
|
||||
|
||||
b.ToTable("file_access_download_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -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")
|
||||
|
||||
@ -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<IActionResult> ApiLogin(string email, string password)
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -23,6 +23,8 @@
|
||||
<dl>
|
||||
<dt>API Base URL</dt>
|
||||
<dd>@Model.Options.ApiBaseUrl</dd>
|
||||
<dt>Web Base URL</dt>
|
||||
<dd>@Model.Options.WebBaseUrl</dd>
|
||||
<dt>web_login Client ID</dt>
|
||||
<dd>@(string.IsNullOrWhiteSpace(Model.Options.WebLoginClientId) ? "not configured" : Model.Options.WebLoginClientId)</dd>
|
||||
<dt>Service Client ID</dt>
|
||||
@ -40,6 +42,9 @@
|
||||
<form method="post" asp-action="RedirectLogin">
|
||||
<button type="submit">Start Redirect Login</button>
|
||||
</form>
|
||||
<form method="post" asp-action="Logout">
|
||||
<button type="submit">Direct Logout</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
|
||||
@ -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=",
|
||||
|
||||
@ -150,12 +150,35 @@ public class AccountController : Controller
|
||||
return RedirectToAction("Index", "Home", new { area = string.Empty });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> 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<IActionResult> Logout()
|
||||
public async Task<IActionResult> 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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user