diff --git a/docs/INSTALL.md b/docs/INSTALL.md index eaa1853..5313971 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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` 一致 diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 62a0375..46dce14 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -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(); builder.Services.AddDbContext(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() diff --git a/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj b/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj index a5b3d7d..8cade53 100644 --- a/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj +++ b/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj @@ -6,6 +6,7 @@ + diff --git a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs index 097c8b4..5db34ce 100644 --- a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs +++ b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs @@ -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 + : IdentityDbContext, IDataProtectionKeyContext { public MemberCenterDbContext(DbContextOptions options) : base(options) @@ -27,6 +28,7 @@ public class MemberCenterDbContext public DbSet AuthResourceScopes => Set(); public DbSet AuthClientUsagePermissions => Set(); public DbSet FileAccessDownloadTokens => Set(); + public DbSet DataProtectionKeys { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260430200729_AddDataProtectionKeys.Designer.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260430200729_AddDataProtectionKeys.Designer.cs new file mode 100644 index 0000000..6cf8a61 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260430200729_AddDataProtectionKeys.Designer.cs @@ -0,0 +1,1321 @@ +// +using System; +using System.Collections.Generic; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(MemberCenterDbContext))] + [Migration("20260430200729_AddDataProtectionKeys")] + partial class AddDataProtectionKeys + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("ActorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthClientUsagePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Usage") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Usage", "Scope") + .IsUnique(); + + b.ToTable("auth_client_usage_permissions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowDelegatedToken") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Audience") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequireTenant") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Audience") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("auth_resources", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ResourceId") + .HasColumnType("uuid"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Scope") + .HasDatabaseName("idx_auth_resource_scopes_scope"); + + b.HasIndex("ResourceId", "Scope") + .IsUnique(); + + b.ToTable("auth_resource_scopes", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlacklistedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("BlacklistedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("email_blacklist", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_email_verifications_email"); + + b.HasIndex("TenantId"); + + b.ToTable("email_verifications", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.FileAccessDownloadToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IssuedByClientId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastValidatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("GET"); + + b.Property("ObjectKey") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasDefaultValue("files:download.read"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("newsletter_lists", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ListId") + .HasColumnType("uuid"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_newsletter_subscriptions_email"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_newsletter_subscriptions_list_id"); + + b.HasIndex("UserId"); + + b.HasIndex("ListId", "Email") + .IsUnique(); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.SystemFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("system_flags", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property>("Domains") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.HasKey("Id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("unsubscribe_tokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressLine2") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressMetaJson") + .HasColumnType("jsonb"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("District") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RecipientName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RecipientPhone") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StateRegion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Usage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("shipping"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .HasDatabaseName("idx_user_addresses_user_id"); + + 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); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Department") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("unspecified"); + + b.Property("InvoiceTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("JobTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LandlinePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MobilePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NickName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Remark") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TaxId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("UserId"); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("BlacklistedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BlacklistedBy") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlacklisted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResourceScope", b => + { + b.HasOne("MemberCenter.Domain.Entities.AuthResource", "Resource") + .WithMany("Scopes") + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resource"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .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") + .WithMany("NewsletterLists") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterList", "List") + .WithMany("Subscriptions") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("List"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterSubscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany("Addresses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithOne("Profile") + .HasForeignKey("MemberCenter.Domain.Entities.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuthResource", b => + { + b.Navigation("Scopes"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + b.Navigation("NewsletterLists"); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b => + { + b.Navigation("Addresses"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260430200729_AddDataProtectionKeys.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260430200729_AddDataProtectionKeys.cs new file mode 100644 index 0000000..23b14db --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260430200729_AddDataProtectionKeys.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddDataProtectionKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FriendlyName = table.Column(type: "text", nullable: true), + Xml = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataProtectionKeys"); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs index d938bb0..6faf3e2 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs @@ -783,6 +783,25 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.ToTable("users", (string)null); }); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index 9bdc2b6..19f55e1 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -19,7 +19,8 @@ EnvLoader.LoadDotEnvIfDevelopment(); var builder = WebApplication.CreateBuilder(args); builder.Services.AddDataProtection() - .SetApplicationName("MemberCenter"); + .SetApplicationName("MemberCenter") + .PersistKeysToDbContext(); builder.Services.AddDbContext(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 {