feat: Enhance webhook validation and add webhook client creation command
This commit is contained in:
parent
e9712fb1f7
commit
620a1ae237
@ -8,4 +8,5 @@ Jwt__Audience=send_engine
|
||||
Jwt__SigningKey=change_me_jwt_signing_key
|
||||
Webhook__Secrets__member_center=change_me_webhook_secret
|
||||
Webhook__TimestampSkewSeconds=300
|
||||
Webhook__AllowNullTenantClient=false
|
||||
Ses__SkipSignatureValidation=true
|
||||
|
||||
8
AGENTS.md
Normal file
8
AGENTS.md
Normal file
@ -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.
|
||||
@ -6,3 +6,11 @@
|
||||
- 預設由 API 啟動時自動執行(`Db__AutoMigrate=true`)
|
||||
- 需要關閉時請設定 `Db__AutoMigrate=false`
|
||||
- 手動執行可用 `dotnet run --project src/SendEngine.Installer -- migrate`
|
||||
- Webhook Auth 初始化(不使用 SQL 檔,改用 Installer):
|
||||
- 使用 Installer 建立 webhook client(`id` 自動隨機產生):
|
||||
- `dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <tenant_uuid> --client-id <client_id> --name <display_name> --scopes <scope1,scope2>`
|
||||
- 例如:`dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id 11111111-1111-1111-1111-111111111111 --client-id member-center-webhook --name "Member Center Webhook" --scopes newsletter:events.write`
|
||||
- 建立成功後,Member Center webhook header `X-Client-Id` 請帶回傳的 `id`
|
||||
- Webhook 驗證規則為 tenant 綁定:`auth_clients.tenant_id` 必須等於 payload `tenant_id`
|
||||
- 不支援 `X-Client-Id` fallback
|
||||
- 預設拒絕 `tenant_id = NULL` 的通用 client(`Webhook__AllowNullTenantClient=false`)
|
||||
|
||||
@ -29,6 +29,9 @@ Header 建議:
|
||||
- nonce 不可重複(重放防護)
|
||||
- signature 必須匹配
|
||||
- client 必須存在且為 active
|
||||
- `auth_clients.tenant_id` 必須與 payload `tenant_id` 一致
|
||||
- 不使用任何 `X-Client-Id` fallback;缺少 tenant 對應 client 時應由來源端跳過發送
|
||||
- 預設拒絕 `auth_clients.tenant_id = NULL` 的通用 client(可透過 `Webhook__AllowNullTenantClient=true` 明確開啟)
|
||||
|
||||
### 3. Send Engine → Member Center 回寫
|
||||
使用 OAuth2 Client Credentials(Send Engine 作為 client)
|
||||
|
||||
23
src/SendEngine.Api/Dockerfile
Normal file
23
src/SendEngine.Api/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY ["Directory.Build.props", "./"]
|
||||
COPY ["NuGet.Config", "./"]
|
||||
COPY ["src/SendEngine.Domain/SendEngine.Domain.csproj", "src/SendEngine.Domain/"]
|
||||
COPY ["src/SendEngine.Application/SendEngine.Application.csproj", "src/SendEngine.Application/"]
|
||||
COPY ["src/SendEngine.Infrastructure/SendEngine.Infrastructure.csproj", "src/SendEngine.Infrastructure/"]
|
||||
COPY ["src/SendEngine.Api/SendEngine.Api.csproj", "src/SendEngine.Api/"]
|
||||
|
||||
RUN dotnet restore "src/SendEngine.Api/SendEngine.Api.csproj"
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish "src/SendEngine.Api/SendEngine.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "SendEngine.Api.dll"]
|
||||
@ -193,9 +193,26 @@ app.MapPost("/api/send-jobs/{id:guid}/cancel", async (HttpContext httpContext, G
|
||||
|
||||
app.MapPost("/webhooks/subscriptions", async (HttpContext httpContext, SubscriptionEventRequest request, SendEngineDbContext db) =>
|
||||
{
|
||||
if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty || request.Subscriber.Id == Guid.Empty)
|
||||
{
|
||||
return Results.UnprocessableEntity(new { error = "tenant_id_list_id_subscriber_id_required" });
|
||||
}
|
||||
|
||||
if (!IsSupportedSubscriptionEvent(request.EventType))
|
||||
{
|
||||
return Results.UnprocessableEntity(new { error = "unsupported_event_type" });
|
||||
}
|
||||
|
||||
var secret = builder.Configuration["Webhook:Secrets:member_center"] ?? string.Empty;
|
||||
var skewSeconds = builder.Configuration.GetValue("Webhook:TimestampSkewSeconds", 300);
|
||||
var validation = await WebhookValidator.ValidateAsync(httpContext, db, secret, skewSeconds);
|
||||
var allowNullTenantClient = builder.Configuration.GetValue("Webhook:AllowNullTenantClient", false);
|
||||
var validation = await WebhookValidator.ValidateAsync(
|
||||
httpContext,
|
||||
db,
|
||||
secret,
|
||||
skewSeconds,
|
||||
request.TenantId,
|
||||
allowNullTenantClient);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
@ -215,16 +232,41 @@ app.MapPost("/webhooks/subscriptions", async (HttpContext httpContext, Subscript
|
||||
};
|
||||
|
||||
db.EventsInbox.Add(inbox);
|
||||
|
||||
await EnsureListExistsAsync(db, request.TenantId, request.ListId);
|
||||
await ApplySubscriptionSnapshotAsync(db, request);
|
||||
|
||||
inbox.Status = "processed";
|
||||
inbox.ProcessedAt = DateTimeOffset.UtcNow;
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return Results.Conflict(new { error = "duplicate_event_or_unique_violation" });
|
||||
}
|
||||
|
||||
return Results.Ok();
|
||||
}).WithName("SubscriptionWebhook").WithOpenApi();
|
||||
|
||||
app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyncBatchRequest request, SendEngineDbContext db) =>
|
||||
{
|
||||
if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty)
|
||||
{
|
||||
return Results.UnprocessableEntity(new { error = "tenant_id_list_id_required" });
|
||||
}
|
||||
|
||||
var secret = builder.Configuration["Webhook:Secrets:member_center"] ?? string.Empty;
|
||||
var skewSeconds = builder.Configuration.GetValue("Webhook:TimestampSkewSeconds", 300);
|
||||
var validation = await WebhookValidator.ValidateAsync(httpContext, db, secret, skewSeconds);
|
||||
var allowNullTenantClient = builder.Configuration.GetValue("Webhook:AllowNullTenantClient", false);
|
||||
var validation = await WebhookValidator.ValidateAsync(
|
||||
httpContext,
|
||||
db,
|
||||
secret,
|
||||
skewSeconds,
|
||||
request.TenantId,
|
||||
allowNullTenantClient);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
@ -244,6 +286,27 @@ app.MapPost("/webhooks/lists/full-sync", async (HttpContext httpContext, FullSyn
|
||||
};
|
||||
|
||||
db.EventsInbox.Add(inbox);
|
||||
|
||||
await EnsureListExistsAsync(db, request.TenantId, request.ListId);
|
||||
foreach (var subscriber in request.Subscribers)
|
||||
{
|
||||
if (subscriber.Id == Guid.Empty || string.IsNullOrWhiteSpace(subscriber.Email))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await UpsertSubscriberAndListMemberAsync(
|
||||
db,
|
||||
request.TenantId,
|
||||
request.ListId,
|
||||
subscriber.Id,
|
||||
subscriber.Email,
|
||||
NormalizeStatus(subscriber.Status, "active"),
|
||||
subscriber.Preferences);
|
||||
}
|
||||
|
||||
inbox.Status = "processed";
|
||||
inbox.ProcessedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok();
|
||||
@ -284,3 +347,135 @@ static Guid? GetTenantId(ClaimsPrincipal user)
|
||||
var value = user.FindFirst("tenant_id")?.Value;
|
||||
return Guid.TryParse(value, out var tenantId) ? tenantId : null;
|
||||
}
|
||||
|
||||
static bool IsSupportedSubscriptionEvent(string eventType)
|
||||
{
|
||||
return eventType is "subscription.activated" or "subscription.unsubscribed" or "preferences.updated";
|
||||
}
|
||||
|
||||
static async Task EnsureListExistsAsync(SendEngineDbContext db, Guid tenantId, Guid listId)
|
||||
{
|
||||
var listExists = await db.Lists.AsNoTracking()
|
||||
.AnyAsync(x => x.Id == listId && x.TenantId == tenantId);
|
||||
if (listExists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.Lists.Add(new MailingList
|
||||
{
|
||||
Id = listId,
|
||||
TenantId = tenantId,
|
||||
Name = $"list-{listId:N}",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
static async Task ApplySubscriptionSnapshotAsync(SendEngineDbContext db, SubscriptionEventRequest request)
|
||||
{
|
||||
var status = request.EventType switch
|
||||
{
|
||||
"subscription.activated" => "active",
|
||||
"subscription.unsubscribed" => "unsubscribed",
|
||||
"preferences.updated" => NormalizeStatus(request.Subscriber.Status, "active"),
|
||||
_ => "active"
|
||||
};
|
||||
|
||||
await UpsertSubscriberAndListMemberAsync(
|
||||
db,
|
||||
request.TenantId,
|
||||
request.ListId,
|
||||
request.Subscriber.Id,
|
||||
request.Subscriber.Email,
|
||||
status,
|
||||
request.Subscriber.Preferences);
|
||||
}
|
||||
|
||||
static async Task UpsertSubscriberAndListMemberAsync(
|
||||
SendEngineDbContext db,
|
||||
Guid tenantId,
|
||||
Guid listId,
|
||||
Guid subscriberId,
|
||||
string email,
|
||||
string status,
|
||||
Dictionary<string, object>? preferences)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var normalizedEmail = email.Trim().ToLowerInvariant();
|
||||
var effectiveSubscriberId = subscriberId;
|
||||
|
||||
var subscriber = await db.Subscribers
|
||||
.FirstOrDefaultAsync(x => x.Id == subscriberId && x.TenantId == tenantId);
|
||||
if (subscriber is null)
|
||||
{
|
||||
subscriber = await db.Subscribers
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Email == normalizedEmail);
|
||||
if (subscriber is not null)
|
||||
{
|
||||
effectiveSubscriberId = subscriber.Id;
|
||||
}
|
||||
}
|
||||
|
||||
var preferenceJson = preferences is null ? null : JsonSerializer.Serialize(preferences);
|
||||
if (subscriber is null)
|
||||
{
|
||||
subscriber = new Subscriber
|
||||
{
|
||||
Id = subscriberId,
|
||||
TenantId = tenantId,
|
||||
Email = normalizedEmail,
|
||||
Status = status,
|
||||
Preferences = preferenceJson,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
db.Subscribers.Add(subscriber);
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriber.Email = normalizedEmail;
|
||||
subscriber.Status = status;
|
||||
subscriber.Preferences = preferenceJson;
|
||||
subscriber.UpdatedAt = now;
|
||||
}
|
||||
|
||||
var listMember = await db.ListMembers
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.ListId == listId && x.SubscriberId == effectiveSubscriberId);
|
||||
if (listMember is null)
|
||||
{
|
||||
listMember = new ListMember
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
ListId = listId,
|
||||
SubscriberId = effectiveSubscriberId,
|
||||
Status = status,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
db.ListMembers.Add(listMember);
|
||||
}
|
||||
else
|
||||
{
|
||||
listMember.Status = status;
|
||||
listMember.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
static string NormalizeStatus(string? status, string fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var value = status.Trim().ToLowerInvariant();
|
||||
return value switch
|
||||
{
|
||||
"active" => "active",
|
||||
"unsubscribed" => "unsubscribed",
|
||||
"bounced" => "bounced",
|
||||
"complaint" => "complaint",
|
||||
_ => fallback
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,7 +8,13 @@ namespace SendEngine.Api.Security;
|
||||
|
||||
public static class WebhookValidator
|
||||
{
|
||||
public static async Task<IResult?> ValidateAsync(HttpContext context, SendEngineDbContext db, string secret, int maxSkewSeconds)
|
||||
public static async Task<IResult?> ValidateAsync(
|
||||
HttpContext context,
|
||||
SendEngineDbContext db,
|
||||
string secret,
|
||||
int maxSkewSeconds,
|
||||
Guid payloadTenantId,
|
||||
bool allowNullTenantClient)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
@ -41,8 +47,23 @@ public static class WebhookValidator
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var hasClient = await db.AuthClients.AsNoTracking().AnyAsync(x => x.Id == clientId);
|
||||
if (!hasClient)
|
||||
var authClient = await db.AuthClients.AsNoTracking().FirstOrDefaultAsync(x => x.Id == clientId);
|
||||
if (authClient is null)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (!string.Equals(authClient.Status, "active", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (authClient.TenantId is null && !allowNullTenantClient)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (authClient.TenantId is not null && authClient.TenantId.Value != payloadTenantId)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SendEngine.Domain.Entities;
|
||||
using SendEngine.Infrastructure;
|
||||
using SendEngine.Infrastructure.Data;
|
||||
|
||||
@ -11,6 +12,7 @@ if (command is "-h" or "--help" or "help")
|
||||
Console.WriteLine("SendEngine Installer");
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- migrate");
|
||||
Console.WriteLine(" dotnet run --project src/SendEngine.Installer -- add-webhook-client --tenant-id <uuid> --client-id <string> --name <string> --scopes <csv>");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -32,4 +34,96 @@ if (command == "migrate")
|
||||
return;
|
||||
}
|
||||
|
||||
if (command == "add-webhook-client")
|
||||
{
|
||||
var options = ParseOptions(args);
|
||||
options.TryGetValue("tenant-id", out var tenantIdRaw);
|
||||
options.TryGetValue("client-id", out var clientId);
|
||||
options.TryGetValue("name", out var name);
|
||||
options.TryGetValue("scopes", out var scopesRaw);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantIdRaw) ||
|
||||
string.IsNullOrWhiteSpace(clientId) ||
|
||||
string.IsNullOrWhiteSpace(name) ||
|
||||
string.IsNullOrWhiteSpace(scopesRaw))
|
||||
{
|
||||
Console.WriteLine("Missing required options.");
|
||||
Console.WriteLine("Required: --tenant-id, --client-id, --name, --scopes");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(tenantIdRaw, out var tenantId))
|
||||
{
|
||||
Console.WriteLine("Invalid --tenant-id, expected UUID.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
var scopes = scopesRaw
|
||||
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (scopes.Length == 0)
|
||||
{
|
||||
Console.WriteLine("Invalid --scopes, expected comma-separated values.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
using var scope = provider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SendEngineDbContext>();
|
||||
|
||||
var exists = await db.AuthClients.AsNoTracking().AnyAsync(x => x.ClientId == clientId);
|
||||
if (exists)
|
||||
{
|
||||
Console.WriteLine($"client_id already exists: {clientId}");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
var entity = new AuthClient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
ClientId = clientId,
|
||||
Name = name,
|
||||
Scopes = scopes,
|
||||
Status = "active",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.AuthClients.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine("Webhook client created.");
|
||||
Console.WriteLine($"id={entity.Id}");
|
||||
Console.WriteLine($"tenant_id={entity.TenantId}");
|
||||
Console.WriteLine($"client_id={entity.ClientId}");
|
||||
Console.WriteLine($"scopes={string.Join(",", entity.Scopes)}");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Unknown command: {command}");
|
||||
|
||||
static Dictionary<string, string> ParseOptions(string[] args)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
var token = args[i];
|
||||
if (!token.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = token[2..];
|
||||
if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
result[key] = string.Empty;
|
||||
continue;
|
||||
}
|
||||
|
||||
result[key] = args[i + 1];
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user