feat: Enhance OAuth client management to support platform_service usage and update related API scopes
This commit is contained in:
parent
b355ed9e14
commit
035a7ca821
@ -43,7 +43,7 @@
|
|||||||
- roles / user_roles (Identity)
|
- roles / user_roles (Identity)
|
||||||
- id, name, created_at
|
- id, name, created_at
|
||||||
- OpenIddictApplications
|
- OpenIddictApplications
|
||||||
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|webhook_outbound`)
|
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|webhook_outbound|platform_service`)
|
||||||
- OpenIddictAuthorizations
|
- OpenIddictAuthorizations
|
||||||
- id, application_id, status, subject, type, scopes
|
- id, application_id, status, subject, type, scopes
|
||||||
- OpenIddictTokens
|
- OpenIddictTokens
|
||||||
@ -110,6 +110,9 @@
|
|||||||
4) 呼叫 Member Center API 將 email 加入 `email_blacklist`
|
4) 呼叫 Member Center API 將 email 加入 `email_blacklist`
|
||||||
5) Member Center 對該 email 的訂閱事件全部忽略且不再推送
|
5) Member Center 對該 email 的訂閱事件全部忽略且不再推送
|
||||||
6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`,Member Center 端做租戶邊界驗證
|
6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`,Member Center 端做租戶邊界驗證
|
||||||
|
7) 回寫授權可用:
|
||||||
|
- tenant client scope:`newsletter:events.write`
|
||||||
|
- platform client scope:`newsletter:events.write.global`(SES 聚合事件)
|
||||||
|
|
||||||
### 6.5 註冊後銜接
|
### 6.5 註冊後銜接
|
||||||
1) 使用者完成註冊
|
1) 使用者完成註冊
|
||||||
@ -141,10 +144,11 @@
|
|||||||
- `POST /subscriptions/disable`:Send Engine → Member Center 黑名單回寫
|
- `POST /subscriptions/disable`:Send Engine → Member Center 黑名單回寫
|
||||||
|
|
||||||
### Auth / Scope
|
### Auth / Scope
|
||||||
- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
|
- `tenant_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
|
||||||
- OAuth Client 需區分用途:`tenant_api` 與 `webhook_outbound`(禁止混用)
|
- OAuth Client 需區分用途:`tenant_api` / `webhook_outbound` / `platform_service`(禁止混用)
|
||||||
- 新增 scope:`newsletter:list.read`、`newsletter:events.read`
|
- 新增 scope:`newsletter:list.read`、`newsletter:events.read`
|
||||||
- 新增 scope:`newsletter:events.write`
|
- 新增 scope:`newsletter:events.write`
|
||||||
|
- 新增 scope:`newsletter:events.write.global`
|
||||||
|
|
||||||
### 租戶端取 Token(Client Credentials)
|
### 租戶端取 Token(Client Credentials)
|
||||||
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
|
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
|
||||||
|
|||||||
@ -48,7 +48,10 @@
|
|||||||
- [API] Send Engine 依事件規則處理:
|
- [API] Send Engine 依事件規則處理:
|
||||||
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
|
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
|
||||||
- [API] `complaint`:先在 Send Engine 取消訂閱,再回寫黑名單
|
- [API] `complaint`:先在 Send Engine 取消訂閱,再回寫黑名單
|
||||||
- [API] 呼叫 `POST /subscriptions/disable`(scope: `newsletter:events.write`),body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at`
|
- [API] 呼叫 `POST /subscriptions/disable`:
|
||||||
|
- [API] tenant client 用 `newsletter:events.write`
|
||||||
|
- [API] 平台 client(SES 聚合事件)用 `newsletter:events.write.global`
|
||||||
|
- [API] body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at`
|
||||||
- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送
|
- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送
|
||||||
|
|
||||||
## F-07 會員資料查看
|
## F-07 會員資料查看
|
||||||
|
|||||||
@ -63,13 +63,18 @@
|
|||||||
## OAuth Client 用途分離(強制)
|
## OAuth Client 用途分離(強制)
|
||||||
- `usage=tenant_api`:
|
- `usage=tenant_api`:
|
||||||
- 供租戶站台拿 token 呼叫 Member Center / Send Engine API
|
- 供租戶站台拿 token 呼叫 Member Center / Send Engine API
|
||||||
- scope 僅給業務所需(如 `newsletter:send.write`、`newsletter:list.read`)
|
- scope 僅給業務所需(如 `newsletter:events.write`)
|
||||||
- `usage=webhook_outbound`:
|
- `usage=webhook_outbound`:
|
||||||
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
|
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
|
||||||
- 不可用於租戶 API 呼叫
|
- 不可用於租戶 API 呼叫
|
||||||
- `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準
|
- `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準
|
||||||
|
- `usage=platform_service`:
|
||||||
|
- 供平台級 S2S(例如 SES 聚合事件回寫)
|
||||||
|
- 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global`
|
||||||
|
- `tenant_api` / `platform_service` 建議(且實作要求)`client_type=confidential`
|
||||||
- 管理規則:
|
- 管理規則:
|
||||||
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
||||||
|
- 平台級流程另建 `platform_service` 憑證
|
||||||
- secret 分開輪替,禁止共用
|
- secret 分開輪替,禁止共用
|
||||||
|
|
||||||
## 整合 API / Auth(狀態)
|
## 整合 API / Auth(狀態)
|
||||||
@ -80,13 +85,17 @@
|
|||||||
- `POST /subscriptions/disable`:已實作(Send Engine 回寫黑名單)
|
- `POST /subscriptions/disable`:已實作(Send Engine 回寫黑名單)
|
||||||
|
|
||||||
### Auth / Scope
|
### Auth / Scope
|
||||||
- OAuth Client 需綁定 `tenant_id`
|
- `tenant_api` / `webhook_outbound` 類型需綁定 `tenant_id`
|
||||||
|
- `platform_service` 可不綁定 `tenant_id`
|
||||||
- 新增 scope:
|
- 新增 scope:
|
||||||
- `newsletter:list.read`
|
- `newsletter:list.read`
|
||||||
- `newsletter:events.read`
|
- `newsletter:events.read`
|
||||||
- `newsletter:events.write`
|
- `newsletter:events.write`
|
||||||
|
- `newsletter:events.write.global`
|
||||||
- 發送引擎僅能用上述 scope,禁止 admin 權限
|
- 發送引擎僅能用上述 scope,禁止 admin 權限
|
||||||
- `POST /subscriptions/disable` 需 Bearer token 且包含 `newsletter:events.write`
|
- `POST /subscriptions/disable` 需 Bearer token 且包含下列其一:
|
||||||
|
- `newsletter:events.write`(tenant-scoped)
|
||||||
|
- `newsletter:events.write.global`(platform-scoped,SES 回寫用)
|
||||||
- 建議 Send Engine 使用 client credentials 取 token,不建議使用長效固定 token
|
- 建議 Send Engine 使用 client credentials 取 token,不建議使用長效固定 token
|
||||||
|
|
||||||
### 回寫原因碼(Send Engine -> Member Center)
|
### 回寫原因碼(Send Engine -> Member Center)
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
### 管理者端(統一 UI)
|
### 管理者端(統一 UI)
|
||||||
- UC-11 租戶管理: `/admin/tenants`
|
- UC-11 租戶管理: `/admin/tenants`
|
||||||
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;需選 `usage=tenant_api` 或 `usage=webhook_outbound`)
|
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;可選 `usage=tenant_api` / `webhook_outbound` / `platform_service`;`platform_service` 可不指定 tenant)
|
||||||
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
||||||
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
|
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
|
||||||
- UC-15 審計紀錄查詢: `/admin/audit-logs`
|
- UC-15 審計紀錄查詢: `/admin/audit-logs`
|
||||||
|
|||||||
@ -440,7 +440,7 @@ paths:
|
|||||||
post:
|
post:
|
||||||
summary: Disable email (global blacklist)
|
summary: Disable email (global blacklist)
|
||||||
security: [{ BearerAuth: [] }]
|
security: [{ BearerAuth: [] }]
|
||||||
description: Requires scope `newsletter:events.write`. Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`.
|
description: Requires scope `newsletter:events.write` (tenant client) or `newsletter:events.write.global` (platform client). Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -722,6 +722,10 @@ components:
|
|||||||
openid: OpenID Connect
|
openid: OpenID Connect
|
||||||
email: Email
|
email: Email
|
||||||
profile: Basic profile
|
profile: Basic profile
|
||||||
|
newsletter:list.read: Read newsletter subscriptions by list
|
||||||
|
newsletter:events.read: Read newsletter events
|
||||||
|
newsletter:events.write: Write newsletter events (tenant scoped)
|
||||||
|
newsletter:events.write.global: Write newsletter events (platform scoped)
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
type: http
|
type: http
|
||||||
scheme: bearer
|
scheme: bearer
|
||||||
@ -907,8 +911,10 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
tenant_id: { type: string }
|
tenant_id:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
usage: { type: string, enum: [tenant_api, webhook_outbound] }
|
usage: { type: string, enum: [tenant_api, webhook_outbound, platform_service] }
|
||||||
redirect_uris: { type: array, items: { type: string } }
|
redirect_uris: { type: array, items: { type: string } }
|
||||||
client_type: { type: string, enum: [public, confidential] }
|
client_type: { type: string, enum: [public, confidential] }
|
||||||
|
|||||||
@ -4,4 +4,4 @@ public sealed record TenantRequest(string Name, List<string> Domains, string Sta
|
|||||||
|
|
||||||
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
|
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
|
||||||
|
|
||||||
public sealed record OAuthClientRequest(Guid TenantId, string Name, List<string> RedirectUris, string ClientType);
|
public sealed record OAuthClientRequest(Guid? TenantId, string Name, List<string> RedirectUris, string ClientType, string Usage = "tenant_api");
|
||||||
|
|||||||
@ -41,30 +41,40 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create([FromBody] OAuthClientRequest request)
|
public async Task<IActionResult> Create([FromBody] OAuthClientRequest request)
|
||||||
{
|
{
|
||||||
|
if (!IsValidUsage(request.Usage))
|
||||||
|
{
|
||||||
|
return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
||||||
|
{
|
||||||
|
return BadRequest("tenant_id is required for this usage.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RequiresClientCredentials(request.Usage)
|
||||||
|
&& !string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return BadRequest("client_type must be confidential for this usage.");
|
||||||
|
}
|
||||||
|
|
||||||
var descriptor = new OpenIddictApplicationDescriptor
|
var descriptor = new OpenIddictApplicationDescriptor
|
||||||
{
|
{
|
||||||
ClientId = Guid.NewGuid().ToString("N"),
|
ClientId = Guid.NewGuid().ToString("N"),
|
||||||
DisplayName = request.Name,
|
DisplayName = request.Name,
|
||||||
ClientType = request.ClientType,
|
ClientType = request.ClientType
|
||||||
Permissions =
|
|
||||||
{
|
|
||||||
OpenIddictConstants.Permissions.Endpoints.Authorization,
|
|
||||||
OpenIddictConstants.Permissions.Endpoints.Token,
|
|
||||||
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
|
|
||||||
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
|
|
||||||
OpenIddictConstants.Permissions.ResponseTypes.Code,
|
|
||||||
OpenIddictConstants.Permissions.Scopes.Email,
|
|
||||||
OpenIddictConstants.Permissions.Scopes.Profile,
|
|
||||||
"scp:openid"
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
ApplyPermissions(descriptor, request.Usage);
|
||||||
|
|
||||||
foreach (var uri in request.RedirectUris)
|
foreach (var uri in request.RedirectUris)
|
||||||
{
|
{
|
||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
|
|
||||||
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.ToString());
|
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
|
||||||
|
{
|
||||||
|
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
|
||||||
|
}
|
||||||
|
descriptor.Properties["usage"] = JsonSerializer.SerializeToElement(request.Usage);
|
||||||
|
|
||||||
await _applicationManager.CreateAsync(descriptor);
|
await _applicationManager.CreateAsync(descriptor);
|
||||||
|
|
||||||
@ -100,6 +110,22 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
public async Task<IActionResult> Update(string id, [FromBody] OAuthClientRequest request)
|
public async Task<IActionResult> Update(string id, [FromBody] OAuthClientRequest request)
|
||||||
{
|
{
|
||||||
|
if (!IsValidUsage(request.Usage))
|
||||||
|
{
|
||||||
|
return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
||||||
|
{
|
||||||
|
return BadRequest("tenant_id is required for this usage.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RequiresClientCredentials(request.Usage)
|
||||||
|
&& !string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return BadRequest("client_type must be confidential for this usage.");
|
||||||
|
}
|
||||||
|
|
||||||
var app = await _applicationManager.FindByIdAsync(id);
|
var app = await _applicationManager.FindByIdAsync(id);
|
||||||
if (app is null)
|
if (app is null)
|
||||||
{
|
{
|
||||||
@ -111,12 +137,21 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
|
|
||||||
descriptor.DisplayName = request.Name;
|
descriptor.DisplayName = request.Name;
|
||||||
descriptor.ClientType = request.ClientType;
|
descriptor.ClientType = request.ClientType;
|
||||||
|
ApplyPermissions(descriptor, request.Usage);
|
||||||
descriptor.RedirectUris.Clear();
|
descriptor.RedirectUris.Clear();
|
||||||
foreach (var uri in request.RedirectUris)
|
foreach (var uri in request.RedirectUris)
|
||||||
{
|
{
|
||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.ToString());
|
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
|
||||||
|
{
|
||||||
|
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
descriptor.Properties.Remove("tenant_id");
|
||||||
|
}
|
||||||
|
descriptor.Properties["usage"] = JsonSerializer.SerializeToElement(request.Usage);
|
||||||
|
|
||||||
await _applicationManager.UpdateAsync(app, descriptor);
|
await _applicationManager.UpdateAsync(app, descriptor);
|
||||||
|
|
||||||
@ -141,4 +176,51 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
await _applicationManager.DeleteAsync(app);
|
await _applicationManager.DeleteAsync(app);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsValidUsage(string usage)
|
||||||
|
{
|
||||||
|
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTenantOptionalUsage(string usage)
|
||||||
|
{
|
||||||
|
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RequiresClientCredentials(string usage)
|
||||||
|
{
|
||||||
|
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
|
||||||
|
{
|
||||||
|
descriptor.Permissions.Clear();
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
||||||
|
|
||||||
|
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
||||||
|
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
descriptor.Permissions.Add("scp:newsletter:events.write.global");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
descriptor.Permissions.Add("scp:newsletter:events.write");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email);
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile);
|
||||||
|
descriptor.Permissions.Add("scp:openid");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,7 +35,9 @@ public class SubscriptionsController : ControllerBase
|
|||||||
[HttpPost("disable")]
|
[HttpPost("disable")]
|
||||||
public async Task<IActionResult> Disable([FromBody] DisableSubscriptionRequest request)
|
public async Task<IActionResult> Disable([FromBody] DisableSubscriptionRequest request)
|
||||||
{
|
{
|
||||||
if (!HasScope(User, "newsletter:events.write"))
|
var hasTenantScope = HasScope(User, "newsletter:events.write");
|
||||||
|
var hasGlobalScope = HasScope(User, "newsletter:events.write.global");
|
||||||
|
if (!hasTenantScope && !hasGlobalScope)
|
||||||
{
|
{
|
||||||
return Forbid();
|
return Forbid();
|
||||||
}
|
}
|
||||||
@ -54,6 +56,11 @@ public class SubscriptionsController : ControllerBase
|
|||||||
return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression.");
|
return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
|
||||||
|
{
|
||||||
|
return BadRequest("tenant_id does not match token tenant scope.");
|
||||||
|
}
|
||||||
|
|
||||||
var target = await (
|
var target = await (
|
||||||
from subscription in _dbContext.NewsletterSubscriptions
|
from subscription in _dbContext.NewsletterSubscriptions
|
||||||
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
|
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
|
||||||
@ -90,4 +97,11 @@ public class SubscriptionsController : ControllerBase
|
|||||||
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||||
return values.Contains(scope, StringComparer.Ordinal);
|
return values.Contains(scope, StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryGetTenantId(System.Security.Claims.ClaimsPrincipal user, out Guid tenantId)
|
||||||
|
{
|
||||||
|
tenantId = Guid.Empty;
|
||||||
|
var value = user.FindFirst("tenant_id")?.Value;
|
||||||
|
return !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out tenantId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Identity;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using OpenIddict.Abstractions;
|
using OpenIddict.Abstractions;
|
||||||
using OpenIddict.Server.AspNetCore;
|
using OpenIddict.Server.AspNetCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace MemberCenter.Api.Controllers;
|
namespace MemberCenter.Api.Controllers;
|
||||||
|
|
||||||
@ -13,11 +15,16 @@ public class TokenController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
|
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||||
|
|
||||||
public TokenController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
|
public TokenController(
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
SignInManager<ApplicationUser> signInManager,
|
||||||
|
IOpenIddictApplicationManager applicationManager)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_signInManager = signInManager;
|
_signInManager = signInManager;
|
||||||
|
_applicationManager = applicationManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/oauth/token")]
|
[HttpPost("/oauth/token")]
|
||||||
@ -69,6 +76,61 @@ public class TokenController : ControllerBase
|
|||||||
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.IsClientCredentialsGrantType())
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ClientId))
|
||||||
|
{
|
||||||
|
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
var app = await _applicationManager.FindByClientIdAsync(request.ClientId);
|
||||||
|
if (app is null)
|
||||||
|
{
|
||||||
|
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(
|
||||||
|
authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
|
||||||
|
nameType: OpenIddictConstants.Claims.Name,
|
||||||
|
roleType: OpenIddictConstants.Claims.Role);
|
||||||
|
|
||||||
|
identity.AddClaim(OpenIddictConstants.Claims.Subject, request.ClientId);
|
||||||
|
identity.AddClaim(OpenIddictConstants.Claims.ClientId, request.ClientId);
|
||||||
|
|
||||||
|
var properties = await _applicationManager.GetPropertiesAsync(app);
|
||||||
|
if (properties.TryGetValue("tenant_id", out var tenantValue))
|
||||||
|
{
|
||||||
|
var tenantId = tenantValue.ValueKind == JsonValueKind.String
|
||||||
|
? tenantValue.GetString()
|
||||||
|
: tenantValue.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||||
|
{
|
||||||
|
identity.AddClaim("tenant_id", tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (properties.TryGetValue("usage", out var usageValue))
|
||||||
|
{
|
||||||
|
var usage = usageValue.ValueKind == JsonValueKind.String
|
||||||
|
? usageValue.GetString()
|
||||||
|
: usageValue.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(usage))
|
||||||
|
{
|
||||||
|
identity.AddClaim("client_usage", usage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
principal.SetScopes(request.Scope.GetScopesOrDefault());
|
||||||
|
|
||||||
|
foreach (var claim in principal.Claims)
|
||||||
|
{
|
||||||
|
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
|
||||||
return BadRequest("Unsupported grant type.");
|
return BadRequest("Unsupported grant type.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,13 +56,18 @@ builder.Services.AddOpenIddict()
|
|||||||
.RequireProofKeyForCodeExchange();
|
.RequireProofKeyForCodeExchange();
|
||||||
options.AllowRefreshTokenFlow();
|
options.AllowRefreshTokenFlow();
|
||||||
options.AllowPasswordFlow();
|
options.AllowPasswordFlow();
|
||||||
|
options.AllowClientCredentialsFlow();
|
||||||
|
|
||||||
options.AcceptAnonymousClients();
|
options.AcceptAnonymousClients();
|
||||||
|
|
||||||
options.RegisterScopes(
|
options.RegisterScopes(
|
||||||
OpenIddictConstants.Scopes.OpenId,
|
OpenIddictConstants.Scopes.OpenId,
|
||||||
OpenIddictConstants.Scopes.Email,
|
OpenIddictConstants.Scopes.Email,
|
||||||
OpenIddictConstants.Scopes.Profile);
|
OpenIddictConstants.Scopes.Profile,
|
||||||
|
"newsletter:list.read",
|
||||||
|
"newsletter:events.read",
|
||||||
|
"newsletter:events.write",
|
||||||
|
"newsletter:events.write.global");
|
||||||
|
|
||||||
options.AddDevelopmentEncryptionCertificate();
|
options.AddDevelopmentEncryptionCertificate();
|
||||||
options.AddDevelopmentSigningCertificate();
|
options.AddDevelopmentSigningCertificate();
|
||||||
|
|||||||
@ -61,7 +61,18 @@ public class OAuthClientsController : Controller
|
|||||||
{
|
{
|
||||||
if (!IsValidUsage(model.Usage))
|
if (!IsValidUsage(model.Usage))
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound.");
|
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.TenantId), "Tenant is required for this usage.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RequiresClientCredentials(model.Usage)
|
||||||
|
&& !string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
@ -75,23 +86,7 @@ public class OAuthClientsController : Controller
|
|||||||
? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var descriptor = new OpenIddictApplicationDescriptor
|
var descriptor = BuildDescriptor(clientId, model.Name, model.ClientType, model.Usage);
|
||||||
{
|
|
||||||
ClientId = clientId,
|
|
||||||
DisplayName = model.Name,
|
|
||||||
ClientType = model.ClientType,
|
|
||||||
Permissions =
|
|
||||||
{
|
|
||||||
OpenIddictConstants.Permissions.Endpoints.Authorization,
|
|
||||||
OpenIddictConstants.Permissions.Endpoints.Token,
|
|
||||||
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
|
|
||||||
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
|
|
||||||
OpenIddictConstants.Permissions.ResponseTypes.Code,
|
|
||||||
OpenIddictConstants.Permissions.Scopes.Email,
|
|
||||||
OpenIddictConstants.Permissions.Scopes.Profile,
|
|
||||||
"scp:openid"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (!string.IsNullOrWhiteSpace(clientSecret))
|
if (!string.IsNullOrWhiteSpace(clientSecret))
|
||||||
{
|
{
|
||||||
descriptor.ClientSecret = clientSecret;
|
descriptor.ClientSecret = clientSecret;
|
||||||
@ -102,7 +97,10 @@ public class OAuthClientsController : Controller
|
|||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
|
|
||||||
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
|
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
|
||||||
|
{
|
||||||
|
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
|
||||||
|
}
|
||||||
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
|
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
|
||||||
|
|
||||||
await _applicationManager.CreateAsync(descriptor);
|
await _applicationManager.CreateAsync(descriptor);
|
||||||
@ -134,7 +132,7 @@ public class OAuthClientsController : Controller
|
|||||||
|
|
||||||
return View(new OAuthClientFormViewModel
|
return View(new OAuthClientFormViewModel
|
||||||
{
|
{
|
||||||
TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : Guid.Empty,
|
TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : null,
|
||||||
Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty,
|
Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty,
|
||||||
ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public",
|
ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public",
|
||||||
Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage,
|
Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage,
|
||||||
@ -148,7 +146,18 @@ public class OAuthClientsController : Controller
|
|||||||
{
|
{
|
||||||
if (!IsValidUsage(model.Usage))
|
if (!IsValidUsage(model.Usage))
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound.");
|
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.TenantId), "Tenant is required for this usage.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RequiresClientCredentials(model.Usage)
|
||||||
|
&& !string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
@ -168,12 +177,20 @@ public class OAuthClientsController : Controller
|
|||||||
|
|
||||||
descriptor.DisplayName = model.Name;
|
descriptor.DisplayName = model.Name;
|
||||||
descriptor.ClientType = model.ClientType;
|
descriptor.ClientType = model.ClientType;
|
||||||
|
ApplyPermissions(descriptor, model.Usage);
|
||||||
descriptor.RedirectUris.Clear();
|
descriptor.RedirectUris.Clear();
|
||||||
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
{
|
{
|
||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString());
|
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
|
||||||
|
{
|
||||||
|
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
descriptor.Properties.Remove("tenant_id");
|
||||||
|
}
|
||||||
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
|
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
|
||||||
|
|
||||||
await _applicationManager.UpdateAsync(app, descriptor);
|
await _applicationManager.UpdateAsync(app, descriptor);
|
||||||
@ -225,6 +242,60 @@ public class OAuthClientsController : Controller
|
|||||||
private static bool IsValidUsage(string usage)
|
private static bool IsValidUsage(string usage)
|
||||||
{
|
{
|
||||||
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTenantOptionalUsage(string usage)
|
||||||
|
{
|
||||||
|
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RequiresClientCredentials(string usage)
|
||||||
|
{
|
||||||
|
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage)
|
||||||
|
{
|
||||||
|
var descriptor = new OpenIddictApplicationDescriptor
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
DisplayName = name,
|
||||||
|
ClientType = clientType
|
||||||
|
};
|
||||||
|
|
||||||
|
ApplyPermissions(descriptor, usage);
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
|
||||||
|
{
|
||||||
|
descriptor.Permissions.Clear();
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
||||||
|
|
||||||
|
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
||||||
|
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
descriptor.Permissions.Add("scp:newsletter:events.write.global");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
descriptor.Permissions.Add("scp:newsletter:events.write");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization);
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode);
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken);
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code);
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email);
|
||||||
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile);
|
||||||
|
descriptor.Permissions.Add("scp:openid");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,7 @@ namespace MemberCenter.Web.Models.Admin;
|
|||||||
|
|
||||||
public sealed class OAuthClientFormViewModel
|
public sealed class OAuthClientFormViewModel
|
||||||
{
|
{
|
||||||
[Required]
|
public Guid? TenantId { get; set; }
|
||||||
public Guid TenantId { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
<select asp-for="Usage">
|
<select asp-for="Usage">
|
||||||
<option value="tenant_api">tenant_api</option>
|
<option value="tenant_api">tenant_api</option>
|
||||||
<option value="webhook_outbound">webhook_outbound</option>
|
<option value="webhook_outbound">webhook_outbound</option>
|
||||||
|
<option value="platform_service">platform_service</option>
|
||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="Usage"></span>
|
<span asp-validation-for="Usage"></span>
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
<select asp-for="Usage">
|
<select asp-for="Usage">
|
||||||
<option value="tenant_api">tenant_api</option>
|
<option value="tenant_api">tenant_api</option>
|
||||||
<option value="webhook_outbound">webhook_outbound</option>
|
<option value="webhook_outbound">webhook_outbound</option>
|
||||||
|
<option value="platform_service">platform_service</option>
|
||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="Usage"></span>
|
<span asp-validation-for="Usage"></span>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user