feat: add data protection key management and update authentication settings

- Added support for data protection keys by integrating Entity Framework Core for key persistence.
- Updated `Program.cs` and `MemberCenterDbContext.cs` to configure data protection services.
- Introduced new migration to create `DataProtectionKeys` table in the database.
- Enhanced cookie settings for authentication to enforce security policies (SameSite=None, Secure=Always).
- Updated installation documentation with new authentication configuration options.
This commit is contained in:
Warren Chen 2026-05-01 05:55:10 +09:00
parent e77fdec76b
commit 6729f91275
8 changed files with 1399 additions and 13 deletions

View File

@ -41,6 +41,9 @@
ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
Auth__Issuer=http://localhost:7850/
Auth__WebLoginUrl=http://localhost:5080/account/login
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__Resources__MemberCenter__Audience=member_center_api
Auth__Resources__SendEngine__Audience=send_engine_api
Auth__Resources__FileAccess__Audience=file_access_api
@ -59,6 +62,12 @@ SendEngine__WebhookSecret=change-me
- 規劃上將收斂為 DB resource registry`.env` 僅作為初始 seed / 部署覆寫來源,不應再為每個新服務新增平行 hardcoded key。
- `File Access` 已直接採用 resource registry 形式,不新增第三組硬編碼 audience 判斷。
OIDC / Redirect login 設定說明:
- `Auth__WebLoginUrl`: API `/oauth/authorize` 未登入時導向的 Web login URL。
- `Auth__AllowedLoginReturnUrlPrefixes`: Web login 成功後允許 redirect 回去的 URL prefix通常填 API issuer/base URL。
- `Auth__AllowedLogoutReturnUrlPrefixes`: Web logout 後允許 redirect 的 URL prefix。
- Identity cookie 固定使用 `SameSite=None``Secure=Always``Path=/`,因此 stage/prod 必須使用 HTTPS。
`SendEngine` 設定說明:
- `SendEngine__BaseUrl`: Send Engine API base URL
- `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致

View File

@ -25,7 +25,8 @@ var issuerUri = ParseAbsoluteUriOrThrow(issuer, "Auth:Issuer");
var allowInsecureHttp = builder.Configuration.GetValue("Auth:AllowInsecureHttp", false);
builder.Services.AddDataProtection()
.SetApplicationName("MemberCenter");
.SetApplicationName("MemberCenter")
.PersistKeysToDbContext<MemberCenterDbContext>();
builder.Services.AddDbContext<MemberCenterDbContext>(options =>
{
@ -61,11 +62,9 @@ builder.Services.AddAuthentication(options =>
builder.Services.ConfigureApplicationCookie(options =>
{
var cookieDomain = builder.Configuration["Auth:CookieDomain"];
if (!string.IsNullOrWhiteSpace(cookieDomain))
{
options.Cookie.Domain = cookieDomain;
}
options.Cookie.Path = "/";
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
builder.Services.AddOpenIddict()

View File

@ -6,6 +6,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">

View File

@ -1,12 +1,13 @@
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Persistence;
public class MemberCenterDbContext
: IdentityDbContext<ApplicationUser, ApplicationRole, Guid>
: IdentityDbContext<ApplicationUser, ApplicationRole, Guid>, IDataProtectionKeyContext
{
public MemberCenterDbContext(DbContextOptions<MemberCenterDbContext> options)
: base(options)
@ -27,6 +28,7 @@ public class MemberCenterDbContext
public DbSet<AuthResourceScope> AuthResourceScopes => Set<AuthResourceScope>();
public DbSet<AuthClientUsagePermission> AuthClientUsagePermissions => Set<AuthClientUsagePermission>();
public DbSet<FileAccessDownloadToken> FileAccessDownloadTokens => Set<FileAccessDownloadToken>();
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddDataProtectionKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DataProtectionKeys",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
FriendlyName = table.Column<string>(type: "text", nullable: true),
Xml = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DataProtectionKeys");
}
}
}

View File

@ -783,6 +783,25 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("text");
b.Property<string>("Xml")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")

View File

@ -19,7 +19,8 @@ EnvLoader.LoadDotEnvIfDevelopment();
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection()
.SetApplicationName("MemberCenter");
.SetApplicationName("MemberCenter")
.PersistKeysToDbContext<MemberCenterDbContext>();
builder.Services.AddDbContext<MemberCenterDbContext>(options =>
{
@ -70,11 +71,9 @@ if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(goo
builder.Services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/account/login";
var cookieDomain = builder.Configuration["Auth:CookieDomain"];
if (!string.IsNullOrWhiteSpace(cookieDomain))
{
options.Cookie.Domain = cookieDomain;
}
options.Cookie.Path = "/";
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Events = new CookieAuthenticationEvents
{