From ae6edae39c2d8356233ce2ddb0aa2bbd061e5d81 Mon Sep 17 00:00:00 2001 From: warrenchen Date: Tue, 17 Feb 2026 17:55:54 +0900 Subject: [PATCH] feat: Enhance Send Engine integration with webhook publishing and configuration options --- .env.example | 2 + AGENTS.md | 8 ++ docs/DESIGN.md | 6 +- docs/FLOWS.md | 5 +- docs/INSTALL.md | 8 ++ docs/MESSMAIL.md | 1 + docs/OPENAPI.md | 29 ++++ docs/UI.md | 4 +- docs/openapi.yaml | 39 +++++- src/MemberCenter.Api/Program.cs | 6 +- .../ISendEngineWebhookPublisher.cs | 12 ++ .../Configuration/SendEngineWebhookOptions.cs | 9 ++ .../Services/NewsletterService.cs | 41 +++++- .../Services/SendEngineWebhookPublisher.cs | 126 ++++++++++++++++++ .../Admin/OAuthClientsController.cs | 48 ++++++- .../Models/Admin/OAuthClientFormViewModel.cs | 5 + src/MemberCenter.Web/Program.cs | 6 +- .../Views/Admin/OAuthClients/Create.cshtml | 20 ++- .../Views/Admin/OAuthClients/Edit.cshtml | 20 ++- .../Views/Admin/OAuthClients/Index.cshtml | 4 +- .../Views/Admin/Tenants/Index.cshtml | 3 +- 21 files changed, 377 insertions(+), 25 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/MemberCenter.Application/Abstractions/ISendEngineWebhookPublisher.cs create mode 100644 src/MemberCenter.Infrastructure/Configuration/SendEngineWebhookOptions.cs create mode 100644 src/MemberCenter.Infrastructure/Services/SendEngineWebhookPublisher.cs diff --git a/.env.example b/.env.example index 67a03ae..b4bab16 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ ASPNETCORE_ENVIRONMENT=Development ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres +SendEngine__BaseUrl=http://localhost:6060 +SendEngine__WebhookSecret=change-me diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b0f7ba6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS.md + +## Dotnet Execution Policy + +- For `.NET` commands (`dotnet restore`, `dotnet build`, `dotnet test`, `dotnet run`), run in sandbox first. +- If the command fails or hangs due to sandbox limits (for example restore/build stalls), rerun with `sandbox_permissions: "require_escalated"`. +- The escalation request must include a short justification explaining that sandbox restrictions are blocking normal .NET execution. +- Do not change project paths or command intent when escalating; rerun the same command with elevated permissions. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 632cda8..11496b7 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -43,7 +43,7 @@ - roles / user_roles (Identity) - id, name, created_at - OpenIddictApplications - - id, client_id, client_secret, display_name, permissions, redirect_uris, properties + - id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|webhook_outbound`) - OpenIddictAuthorizations - id, application_id, status, subject, type, scopes - OpenIddictTokens @@ -100,7 +100,8 @@ ### 6.6 Send Engine 事件同步(Member Center → Send Engine) 1) Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`) 2) 以 webhook 推送至 Send Engine(簽章與重放防護) -3) Send Engine 驗證 tenant scope,更新本地名單快照 +3) `X-Client-Id` 使用 Send Engine `auth_clients.id`(可按 tenant 做設定覆蓋) +4) Send Engine 驗證 tenant scope,更新本地名單快照 ### 6.7 Send Engine 退信/黑名單回寫(選用) 1) Send Engine 判定黑名單(例如 hard bounce / complaint) @@ -138,6 +139,7 @@ ### Auth / Scope - OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界 +- OAuth Client 需區分用途:`tenant_api` 與 `webhook_outbound`(禁止混用) - 新增 scope:`newsletter:list.read`、`newsletter:events.read` - 新增 scope:`newsletter:events.write` diff --git a/docs/FLOWS.md b/docs/FLOWS.md index ac7d5e1..243e687 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -38,7 +38,10 @@ ## F-10 Send Engine 事件同步(Member Center → Send Engine) - [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`(scope: `newsletter:events.write`) -- [API] Send Engine 驗證 tenant scope + 簽章/重放防護 +- [API] Header 使用 `X-Signature` / `X-Timestamp` / `X-Nonce` / `X-Client-Id` +- [API] `X-Client-Id` 對應 Send Engine `auth_clients.id`(UUID) +- [API] Member Center 以 `SendEngine__TenantWebhookClientIds__{tenant_uuid}` 選擇 header 值 +- [API] Send Engine 驗證簽章 + timestamp + nonce(重放防護)後入庫 - [API] Send Engine 更新名單快照 ## F-11 黑名單回寫(Send Engine → Member Center) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index b4ae8ce..717e31d 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -40,8 +40,16 @@ ``` ASPNETCORE_ENVIRONMENT=Development ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres +SendEngine__BaseUrl=http://localhost:6060 +SendEngine__WebhookSecret=change-me +SendEngine__TenantWebhookClientIds__REPLACE_WITH_TENANT_ID=11111111-1111-1111-1111-111111111111 ``` +`SendEngine` 設定說明: +- `SendEngine__BaseUrl`: Send Engine API base URL +- `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致 +- `SendEngine__TenantWebhookClientIds__{tenant_uuid}`: 每個 tenant 對應的 `X-Client-Id`(Send Engine `auth_clients.id`) + ### 1) `installer init` 用途:首次安裝(含 migrations + seed + superuser) diff --git a/docs/MESSMAIL.md b/docs/MESSMAIL.md index 6b56613..49c3d56 100644 --- a/docs/MESSMAIL.md +++ b/docs/MESSMAIL.md @@ -53,6 +53,7 @@ End User 租戶站台沒有真人帳號,使用 **Client Credentials** 取得 access token。 - 向 Member Center 申請 OAuth Client +- OAuth Client 必須使用 `usage=tenant_api`(不可使用 `webhook_outbound`) - scope 依用途最小化 建議 scopes: diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 33403fc..02c65a6 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -41,6 +41,35 @@ - 與訂閱者資料(preferences、unsubscribe token)相關的查詢與寫入,一律必須帶 `list_id + email` 做租戶邊界約束。 - 不提供僅靠 `email` 或單純 `subscription_id` 的公開查詢/操作端點。 +## Webhook Auth(Member Center -> Send Engine) +- Header(對齊 Send Engine 規格): + - `X-Signature` + - `X-Timestamp` + - `X-Nonce` + - `X-Client-Id` +- `X-Client-Id` 來源: + - 由 Send Engine 的 `auth_clients.id`(UUID)提供 + - Member Center 以 `SendEngine__TenantWebhookClientIds__{tenant_uuid}` 設定每個租戶的對應值 +- 簽章建議: + - `HMAC-SHA256(secret, "{raw_body}")`(對齊 Send Engine 驗證器) +- 驗證規則: + - timestamp 在允許時間窗(例如 ±5 分鐘) + - nonce 不可重複(防重放) + - `X-Client-Id` 必須存在且 active,且 tenant 綁定一致 + - signature 必須匹配 + +## OAuth Client 用途分離(強制) +- `usage=tenant_api`: + - 供租戶站台拿 token 呼叫 Member Center / Send Engine API + - scope 僅給業務所需(如 `newsletter:send.write`、`newsletter:list.read`) +- `usage=webhook_outbound`: + - 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途 + - 不可用於租戶 API 呼叫 + - `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準 +- 管理規則: + - 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`) + - secret 分開輪替,禁止共用 + ## 待新增 API / Auth(規劃中) ### API - `GET /newsletter/subscriptions?list_id=...`:回傳清單內所有訂閱(供發送引擎同步用) diff --git a/docs/UI.md b/docs/UI.md index 69074c2..cfafeee 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -11,7 +11,7 @@ ### 管理者端 - 租戶管理(Tenant CRUD) -- OAuth Client 管理(redirect_uris / scopes / client_id / client_secret) +- OAuth Client 管理(usage / redirect_uris / scopes / client_id / client_secret) - 電子報清單管理(Lists CRUD) - 訂閱查詢 / 匯出 - 審計紀錄查詢 @@ -48,7 +48,7 @@ ### 管理者端(統一 UI) - UC-11 租戶管理: `/admin/tenants` -- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉) +- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;需選 `usage=tenant_api` 或 `usage=webhook_outbound`) - UC-13 電子報清單管理: `/admin/newsletter-lists` - UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export` - UC-15 審計紀錄查詢: `/admin/audit-logs` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5b41f60..bd41a33 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -466,8 +466,25 @@ paths: /webhooks/subscriptions: post: summary: (NOTE) Member Center -> Send Engine subscription events webhook - description: This endpoint is implemented by Send Engine; listed here as an integration note. Require HMAC signature + timestamp/nonce to prevent replay. + description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID). security: [] + parameters: + - in: header + name: X-Signature + required: true + schema: { type: string } + - in: header + name: X-Timestamp + required: true + schema: { type: string } + - in: header + name: X-Nonce + required: true + schema: { type: string, format: uuid } + - in: header + name: X-Client-Id + required: true + schema: { type: string } requestBody: required: true content: @@ -481,8 +498,25 @@ paths: /webhooks/lists/full-sync: post: summary: (NOTE) Member Center -> Send Engine full list sync webhook - description: This endpoint is implemented by Send Engine; listed here as an integration note. Require HMAC signature + timestamp/nonce to prevent replay. + description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID). security: [] + parameters: + - in: header + name: X-Signature + required: true + schema: { type: string } + - in: header + name: X-Timestamp + required: true + schema: { type: string } + - in: header + name: X-Nonce + required: true + schema: { type: string, format: uuid } + - in: header + name: X-Client-Id + required: true + schema: { type: string } requestBody: required: true content: @@ -871,5 +905,6 @@ components: id: { type: string } tenant_id: { type: string } name: { type: string } + usage: { type: string, enum: [tenant_api, webhook_outbound] } redirect_uris: { type: array, items: { type: string } } client_type: { type: string, enum: [public, confidential] } diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index d13c99b..16c14ac 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -44,8 +44,7 @@ builder.Services.AddOpenIddict() .AddCore(options => { options.UseEntityFrameworkCore() - .UseDbContext() - .ReplaceDefaultEntities(); + .UseDbContext(); }) .AddServer(options => { @@ -90,6 +89,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/src/MemberCenter.Application/Abstractions/ISendEngineWebhookPublisher.cs b/src/MemberCenter.Application/Abstractions/ISendEngineWebhookPublisher.cs new file mode 100644 index 0000000..f786673 --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/ISendEngineWebhookPublisher.cs @@ -0,0 +1,12 @@ +using MemberCenter.Application.Models.Newsletter; + +namespace MemberCenter.Application.Abstractions; + +public interface ISendEngineWebhookPublisher +{ + Task PublishSubscriptionEventAsync( + string eventType, + Guid tenantId, + SubscriptionDto subscription, + CancellationToken cancellationToken = default); +} diff --git a/src/MemberCenter.Infrastructure/Configuration/SendEngineWebhookOptions.cs b/src/MemberCenter.Infrastructure/Configuration/SendEngineWebhookOptions.cs new file mode 100644 index 0000000..8e521d1 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Configuration/SendEngineWebhookOptions.cs @@ -0,0 +1,9 @@ +namespace MemberCenter.Infrastructure.Configuration; + +public sealed class SendEngineWebhookOptions +{ + public string? BaseUrl { get; set; } + public string SubscriptionEventsPath { get; set; } = "/webhooks/subscriptions"; + public string? WebhookSecret { get; set; } + public Dictionary TenantWebhookClientIds { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs index 3edad4c..db555b1 100644 --- a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs +++ b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs @@ -4,6 +4,7 @@ using MemberCenter.Domain.Constants; using MemberCenter.Domain.Entities; using MemberCenter.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -19,11 +20,19 @@ public sealed class NewsletterService : INewsletterService private readonly MemberCenterDbContext _dbContext; private readonly IEmailBlacklistService _emailBlacklist; + private readonly ISendEngineWebhookPublisher _webhookPublisher; + private readonly ILogger _logger; - public NewsletterService(MemberCenterDbContext dbContext, IEmailBlacklistService emailBlacklist) + public NewsletterService( + MemberCenterDbContext dbContext, + IEmailBlacklistService emailBlacklist, + ISendEngineWebhookPublisher webhookPublisher, + ILogger logger) { _dbContext = dbContext; _emailBlacklist = emailBlacklist; + _webhookPublisher = webhookPublisher; + _logger = logger; } public async Task SubscribeAsync(Guid listId, string email, Dictionary? preferences) @@ -107,7 +116,9 @@ public sealed class NewsletterService : INewsletterService confirmToken.ConsumedAt = DateTimeOffset.UtcNow; await _dbContext.SaveChangesAsync(); - return MapSubscription(confirmToken.Subscription); + var confirmed = MapSubscription(confirmToken.Subscription); + await PublishSubscriptionEventSafeAsync("subscription.activated", confirmed); + return confirmed; } public async Task UnsubscribeAsync(string token) @@ -131,7 +142,9 @@ public sealed class NewsletterService : INewsletterService unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow; await _dbContext.SaveChangesAsync(); - return MapSubscription(unsubscribeToken.Subscription); + var unsubscribed = MapSubscription(unsubscribeToken.Subscription); + await PublishSubscriptionEventSafeAsync("subscription.unsubscribed", unsubscribed); + return unsubscribed; } public async Task IssueUnsubscribeTokenAsync(Guid listId, string email) @@ -198,7 +211,9 @@ public sealed class NewsletterService : INewsletterService subscription.Preferences = ToJsonDocument(preferences); await _dbContext.SaveChangesAsync(); - return MapSubscription(subscription); + var updated = MapSubscription(subscription); + await PublishSubscriptionEventSafeAsync("preferences.updated", updated); + return updated; } public async Task> ListSubscriptionsAsync(Guid listId) @@ -253,4 +268,22 @@ public sealed class NewsletterService : INewsletterService .Include(t => t.Subscription) .FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null); } + + private async Task PublishSubscriptionEventSafeAsync(string eventType, SubscriptionDto subscription) + { + var tenantId = await _dbContext.NewsletterLists + .Where(x => x.Id == subscription.ListId) + .Select(x => x.TenantId) + .FirstOrDefaultAsync(); + + if (tenantId == Guid.Empty) + { + _logger.LogWarning( + "Skip webhook publish because list {ListId} has no tenant mapping", + subscription.ListId); + return; + } + + await _webhookPublisher.PublishSubscriptionEventAsync(eventType, tenantId, subscription); + } } diff --git a/src/MemberCenter.Infrastructure/Services/SendEngineWebhookPublisher.cs b/src/MemberCenter.Infrastructure/Services/SendEngineWebhookPublisher.cs new file mode 100644 index 0000000..58f0eab --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/SendEngineWebhookPublisher.cs @@ -0,0 +1,126 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Models.Newsletter; +using MemberCenter.Infrastructure.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace MemberCenter.Infrastructure.Services; + +public sealed class SendEngineWebhookPublisher : ISendEngineWebhookPublisher +{ + private readonly HttpClient _httpClient; + private readonly IOptions _options; + private readonly ILogger _logger; + + public SendEngineWebhookPublisher( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + _httpClient = httpClient; + _options = options; + _logger = logger; + } + + public async Task PublishSubscriptionEventAsync( + string eventType, + Guid tenantId, + SubscriptionDto subscription, + CancellationToken cancellationToken = default) + { + var options = _options.Value; + if (string.IsNullOrWhiteSpace(options.BaseUrl) + || string.IsNullOrWhiteSpace(options.WebhookSecret)) + { + return; + } + + var clientId = ResolveWebhookClientId(options, tenantId); + if (string.IsNullOrWhiteSpace(clientId)) + { + _logger.LogWarning("Skip webhook publish: missing webhook client id for tenant {TenantId}", tenantId); + return; + } + + var payload = new + { + event_id = Guid.NewGuid(), + event_type = eventType, + tenant_id = tenantId, + list_id = subscription.ListId, + subscriber = new + { + id = subscription.Id, + email = subscription.Email, + status = subscription.Status, + preferences = subscription.Preferences + }, + occurred_at = DateTimeOffset.UtcNow + }; + + var json = JsonSerializer.Serialize(payload); + var body = new StringContent(json, Encoding.UTF8, "application/json"); + + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var nonce = Guid.NewGuid().ToString(); + var signature = ComputeHmacHex(options.WebhookSecret, json); + + var endpoint = BuildEndpoint(options.BaseUrl, options.SubscriptionEventsPath); + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = body + }; + request.Headers.Add("X-Signature", signature); + request.Headers.Add("X-Timestamp", timestamp); + request.Headers.Add("X-Nonce", nonce); + request.Headers.Add("X-Client-Id", clientId); + + try + { + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogWarning( + "Send Engine webhook failed. Status={StatusCode}, TenantId={TenantId}, EventType={EventType}, Body={Body}", + (int)response.StatusCode, + tenantId, + eventType, + responseBody); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Send Engine webhook publish threw an exception for tenant {TenantId}, event {EventType}", tenantId, eventType); + } + } + + private static string BuildEndpoint(string baseUrl, string path) + { + var normalizedBase = baseUrl.TrimEnd('/'); + var normalizedPath = path.StartsWith('/') ? path : $"/{path}"; + return normalizedBase + normalizedPath; + } + + private static string? ResolveWebhookClientId(SendEngineWebhookOptions options, Guid tenantId) + { + var tenantKey = tenantId.ToString(); + if (options.TenantWebhookClientIds.TryGetValue(tenantKey, out var tenantClientId) + && !string.IsNullOrWhiteSpace(tenantClientId)) + { + return tenantClientId; + } + return null; + } + + private static string ComputeHmacHex(string secret, string payload) + { + var key = Encoding.UTF8.GetBytes(secret); + using var hmac = new HMACSHA256(key); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs b/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs index efc3820..fa0dbc0 100644 --- a/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs +++ b/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs @@ -1,4 +1,5 @@ using MemberCenter.Web.Models.Admin; +using MemberCenter.Application.Abstractions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenIddict.Abstractions; @@ -10,10 +11,14 @@ namespace MemberCenter.Web.Controllers.Admin; public class OAuthClientsController : Controller { private readonly IOpenIddictApplicationManager _applicationManager; + private readonly ITenantService _tenantService; - public OAuthClientsController(IOpenIddictApplicationManager applicationManager) + public OAuthClientsController( + IOpenIddictApplicationManager applicationManager, + ITenantService tenantService) { _applicationManager = applicationManager; + _tenantService = tenantService; } [HttpGet("")] @@ -22,12 +27,18 @@ public class OAuthClientsController : Controller var results = new List(); await foreach (var application in _applicationManager.ListAsync()) { + var properties = await _applicationManager.GetPropertiesAsync(application); + var usage = properties.TryGetValue("usage", out var usageElement) + ? usageElement.GetString() + : "tenant_api"; + results.Add(new { id = await _applicationManager.GetIdAsync(application), name = await _applicationManager.GetDisplayNameAsync(application), client_id = await _applicationManager.GetClientIdAsync(application), client_type = await _applicationManager.GetClientTypeAsync(application), + usage = usage, redirect_uris = await _applicationManager.GetRedirectUrisAsync(application) }); } @@ -36,16 +47,26 @@ public class OAuthClientsController : Controller } [HttpGet("create")] - public IActionResult Create() + public async Task Create() { - return View(new OAuthClientFormViewModel()); + var tenants = await _tenantService.ListAsync(); + return View(new OAuthClientFormViewModel + { + Tenants = tenants + }); } [HttpPost("create")] public async Task Create(OAuthClientFormViewModel model) { + if (!IsValidUsage(model.Usage)) + { + ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound."); + } + if (!ModelState.IsValid) { + model.Tenants = await _tenantService.ListAsync(); return View(model); } @@ -82,6 +103,7 @@ public class OAuthClientsController : Controller } descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString()); + descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage); await _applicationManager.CreateAsync(descriptor); @@ -106,21 +128,32 @@ public class OAuthClientsController : Controller var redirectUris = await _applicationManager.GetRedirectUrisAsync(app); var properties = await _applicationManager.GetPropertiesAsync(app); var tenantId = properties.TryGetValue("tenant_id", out var value) ? value.GetString() : string.Empty; + var usage = properties.TryGetValue("usage", out var usageValue) ? usageValue.GetString() : "tenant_api"; + + var tenants = await _tenantService.ListAsync(); return View(new OAuthClientFormViewModel { TenantId = Guid.TryParse(tenantId, out var parsed) ? parsed : Guid.Empty, Name = await _applicationManager.GetDisplayNameAsync(app) ?? string.Empty, ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public", - RedirectUris = string.Join(",", redirectUris.Select(u => u.ToString())) + Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage, + RedirectUris = string.Join(",", redirectUris.Select(u => u.ToString())), + Tenants = tenants }); } [HttpPost("edit/{id}")] public async Task Edit(string id, OAuthClientFormViewModel model) { + if (!IsValidUsage(model.Usage)) + { + ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api or webhook_outbound."); + } + if (!ModelState.IsValid) { + model.Tenants = await _tenantService.ListAsync(); return View(model); } @@ -141,6 +174,7 @@ public class OAuthClientsController : Controller descriptor.RedirectUris.Add(new Uri(uri)); } descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString()); + descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage); await _applicationManager.UpdateAsync(app, descriptor); return RedirectToAction("Index"); @@ -187,4 +221,10 @@ public class OAuthClientsController : Controller return RedirectToAction("Index"); } + + private static bool IsValidUsage(string usage) + { + return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs index a821189..12c0c4d 100644 --- a/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs @@ -13,7 +13,12 @@ public sealed class OAuthClientFormViewModel [Required] public string ClientType { get; set; } = "public"; + [Required] + public string Usage { get; set; } = "tenant_api"; + public string RedirectUris { get; set; } = string.Empty; + public IReadOnlyList Tenants { get; set; } + = Array.Empty(); public string? ClientId { get; set; } public string? ClientSecret { get; set; } diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index 95445a2..dd445bc 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -50,13 +50,15 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); builder.Services.AddOpenIddict() .AddCore(options => { options.UseEntityFrameworkCore() - .UseDbContext() - .ReplaceDefaultEntities(); + .UseDbContext(); }); builder.Services.AddControllersWithViews() diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml b/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml index e8116a0..a3a9e44 100644 --- a/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml +++ b/src/MemberCenter.Web/Views/Admin/OAuthClients/Create.cshtml @@ -3,7 +3,13 @@

Create OAuth Client

- + @@ -11,9 +17,19 @@ - + + + + + diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml b/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml index 5c746db..16a1902 100644 --- a/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml +++ b/src/MemberCenter.Web/Views/Admin/OAuthClients/Edit.cshtml @@ -3,7 +3,13 @@

Edit OAuth Client

- + @@ -11,9 +17,19 @@ - + + + + + diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Index.cshtml b/src/MemberCenter.Web/Views/Admin/OAuthClients/Index.cshtml index c9a7b3c..e2010ac 100644 --- a/src/MemberCenter.Web/Views/Admin/OAuthClients/Index.cshtml +++ b/src/MemberCenter.Web/Views/Admin/OAuthClients/Index.cshtml @@ -26,7 +26,7 @@ } - + @foreach (var item in Model) @@ -34,11 +34,13 @@ var name = (string)item.GetType().GetProperty("name")!.GetValue(item)!; var clientId = (string)item.GetType().GetProperty("client_id")!.GetValue(item)!; var clientType = (string)item.GetType().GetProperty("client_type")!.GetValue(item)!; + var usage = (string)item.GetType().GetProperty("usage")!.GetValue(item)!; var id = (string)item.GetType().GetProperty("id")!.GetValue(item)!; +
NameClient IdType
NameClient IdTypeUsage
@name @clientId @clientType@usage Edit @if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase)) diff --git a/src/MemberCenter.Web/Views/Admin/Tenants/Index.cshtml b/src/MemberCenter.Web/Views/Admin/Tenants/Index.cshtml index a7e57f8..4b67a83 100644 --- a/src/MemberCenter.Web/Views/Admin/Tenants/Index.cshtml +++ b/src/MemberCenter.Web/Views/Admin/Tenants/Index.cshtml @@ -4,13 +4,14 @@

Create

- + @foreach (var tenant in Model) { +
NameDomainsStatus
NameTenant IdDomainsStatus
@tenant.Name@tenant.Id @string.Join(",", tenant.Domains) @tenant.Status