Load certificates from DB and trust forwarded headers

This commit is contained in:
Warren Chen 2026-07-14 04:15:04 +09:00
parent 5b77669ab4
commit 96a1584bbe
35 changed files with 905 additions and 109 deletions

View File

@ -5,6 +5,7 @@ Auth__WebLoginUrl=http://localhost:5080/account/login
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__AllowInternalHttpTokenEndpoint=false
# Optional PFX file overrides. Production normally reads app-managed certificates provisioned by installer into DB.
Auth__Certificates__Signing__Path=
Auth__Certificates__Signing__Password=
Auth__Certificates__Encryption__Path=
@ -19,6 +20,7 @@ Auth__Certificates__Encryption__Previous__0__Password=
DataProtection__Certificate__Previous__0__Path=
DataProtection__Certificate__Previous__0__Password=
Certificates__ExpirationWarningDays=30
ReverseProxy__TrustForwardedHeaders=false
ReverseProxy__KnownProxies=
ReverseProxy__KnownNetworks=
ReverseProxy__ForwardLimit=1

View File

@ -81,21 +81,27 @@ OAuth usage/scope mapping 的正式來源為 DB registryaudience key 只作 s
| Key | 預設值 | 說明 |
|---|---:|---|
| `Auth:Certificates:Signing:Path/Password` | 無 | Production API 必填 PFX。 |
| `Auth:Certificates:Encryption:Path/Password` | 無 | Production API 必填 PFX。 |
| `DataProtection:Certificate:Path/Password` | 無 | Production API/Web 必填且共用 PFX。 |
| `*:Previous:0:Path/Password` | 無 | 前代憑證,可增加索引。 |
| DB `system_flags.certificates:openiddict:signing` | installer 產生 | Production API JWT signing PFX。 |
| DB `system_flags.certificates:openiddict:encryption` | installer 產生 | Production API OpenIddict encryption PFX。 |
| DB `system_flags.certificates:data-protection` | installer 產生 | Production API/Web 共用 Data Protection PFX。 |
| `Auth:Certificates:Signing:Path/Password` | 無 | 選填;覆寫 DB signing PFX。 |
| `Auth:Certificates:Encryption:Path/Password` | 無 | 選填;覆寫 DB encryption PFX。 |
| `DataProtection:Certificate:Path/Password` | 無 | 選填;覆寫 DB Data Protection PFX。 |
| `*:Previous:0:Path/Password` | 無 | 選填;檔案型前代憑證,可增加索引。 |
| `Certificates:ExpirationWarningDays` | `30` | 到期 warning有效範圍 1180。 |
## Reverse proxy
| Key | 預設值 | 說明 |
|---|---:|---|
| `ReverseProxy:TrustForwardedHeaders` | `false` | 設為 `true` 時信任 forwarded headers不要求 `KnownProxies` / `KnownNetworks`;僅可在 app inbound 已由 Security Group / 私有網路限制為可信 proxy 時使用。 |
| `ReverseProxy:KnownProxies` | 空 | 逗號分隔可信 proxy IP。 |
| `ReverseProxy:KnownNetworks` | 空 | 逗號分隔可信 CIDR禁止 `/0`。 |
| `ReverseProxy:ForwardLimit` | `1` | Proxy hop限制 15。 |
Allowlist 都為空時完全忽略 forwarded headers。
`ReverseProxy:TrustForwardedHeaders=true` 會接受 `X-Forwarded-For``X-Forwarded-Proto``X-Forwarded-Host`。此模式適合 AWS ALB / managed reverse proxy private IP 會變動,但 app security group 已只允許該 proxy 連入的環境。
未啟用 `TrustForwardedHeaders` 時,必須設定 `KnownProxies``KnownNetworks` 才會接受 forwarded headersallowlist 都為空時完全忽略 forwarded headers。
## 外部整合與測試旗標

View File

@ -49,6 +49,7 @@ Auth__WebLoginUrl=http://localhost:5080/account/login
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__AllowInternalHttpTokenEndpoint=false
# Optional certificate file overrides. Normally installer provisions app certificates into DB.
Auth__Certificates__Signing__Path=
Auth__Certificates__Signing__Password=
Auth__Certificates__Encryption__Path=
@ -87,19 +88,24 @@ OIDC / Redirect login 設定說明:
- 同 VPC 服務若需直接呼叫私有 HTTP `/oauth/token`,設定 `Auth__AllowInternalHttpTokenEndpoint=true`;此設定只放寬 OAuth endpoint transport不放寬 issuer 或外部 return URL。
- 私有 HTTP listener 必須以 Security Group 限制來源CMS 可使用內部 HTTP token endpoint但驗證 token 時仍必須接受 canonical HTTPS issuer。
- 非 Development 若 `Auth__Issuer` 不是 HTTPSAPI 會拒絕啟動Login / Logout 的外部 HTTP return URL 也會被拒絕。
- TLS 終止於 reverse proxy 時,必須正確設定 trusted proxy讓應用程式只接受 ALB / ingress 提供的 `X-Forwarded-Proto`。
- TLS 終止於 reverse proxy 時,必須讓應用程式接受 proxy 提供的 forwarded headers否則登入與 callback redirect 可能從 `https` 退回 `http`。
Reverse proxy 信任設定:
- 未設定 `ReverseProxy__KnownProxies` / `ReverseProxy__KnownNetworks`API 與 Web 完全忽略 `X-Forwarded-For``X-Forwarded-Proto`
- AWS ALB / managed reverse proxy 的 private IP 會變動時,建議由 Security Group 限制 Member Center 只接受該 proxy 連入,並設定:
- `ReverseProxy__TrustForwardedHeaders=true`
- `ReverseProxy__ForwardLimit=1`
- `ReverseProxy__TrustForwardedHeaders=true` 會接受 `X-Forwarded-For``X-Forwarded-Proto``X-Forwarded-Host`;只有在 app inbound 已由 Security Group / 私有網路限制為可信 proxy 時才可使用。
- 若不使用 `TrustForwardedHeaders`,未設定 `ReverseProxy__KnownProxies` / `ReverseProxy__KnownNetworks`API 與 Web 完全忽略 forwarded headers。
- `ReverseProxy__KnownProxies` 使用逗號分隔 IP例如 `10.0.0.10,10.0.0.11`
- `ReverseProxy__KnownNetworks` 使用逗號分隔 CIDR例如 `10.0.0.0/24,fd00::/64`
- `ReverseProxy__ForwardLimit` 預設為 `1`,只應設為實際 proxy hop 數,允許範圍 `15`
- 不可填入 `0.0.0.0/0``::/0`;正式環境只信任 load balancer / ingress 的固定 IP 或內部網段。
- 不使用 `TrustForwardedHeaders` 而改用 `KnownNetworks` 時,不可填入 `0.0.0.0/0``::/0`;正式環境只信任 load balancer / ingress 的固定 IP 或內部網段。
Web security headers
- Web 全域送出 self-only Content Security Policy禁止 object、外部 frame 與 inline script/style。
- Web 全域送出受限 Content Security Policy禁止 object、外部 frame 與 inline script/style。
- 同時送出 `X-Content-Type-Options: nosniff``X-Frame-Options: DENY``Referrer-Policy: no-referrer` 與受限 `Permissions-Policy`
- 新增外部資產或第三方前端服務時,必須先明確調整 CSP不可直接加入 `'unsafe-inline'` 或萬用來源。
- Google login 啟用時,`form-action` 會額外允許 `https://accounts.google.com`,因為 `/Account/ExternalLogin` 的 form submit 會被 ASP.NET Core external auth challenge 導向 Google OAuth endpoint。
SMTP 密碼儲存:
- `smtp_password``protected:v1:` 開頭的 Data Protection ciphertext 儲存,不需要新增 migration 或重建既有 DB。
@ -107,12 +113,14 @@ SMTP 密碼儲存:
- `DataProtectionKeys` 是解密必要資料不可任意清空Production 應再以外部憑證保護 key ring並將憑證納入備份與輪替程序。
Production 憑證:
- 非 Development 啟動 API 時必須提供 `Auth__Certificates__Signing__Path/Password``Auth__Certificates__Encryption__Path/Password`;不可再使用 development certificates。
- API 與 Web 都必須提供相同的 `DataProtection__Certificate__Path/Password`,用來保護共用 DB key ring。
- 憑證檔必須是含 private key、在有效期內的 PFX密碼只可由 secret manager / environment 注入,不可提交到 repo。
- signing、encryption 與 Data Protection 憑證建議分離;輪替時需保留仍用於驗證舊 token解密舊資料的前一代憑證完成相容輪替後才能移除。
- 前代憑證以 `__Previous__0__Path/Password` 設定,可依序增加 `Previous__1__...`;目前憑證用於新簽發/新 key前代憑證保留舊 token 驗證與 key 解密能力。
- 應監控憑證到期日並先在 Stage 驗證;路徑錯誤、缺 private key或已過期時應用程式會拒絕啟動。
- Installer `init` / `migrate` 會在 DB `system_flags` 自動產生三張 app-managed PFXOpenIddict signing、OpenIddict encryption、Data Protection。
- API 非 Development 啟動時會優先使用 `Auth__Certificates__Signing__Path` / `Auth__Certificates__Encryption__Path`,未設定時改讀 DB。
- Web 非 Development 啟動時會優先使用 `DataProtection__Certificate__Path`,未設定時改讀 DB。
- API 與 Web 必須使用同一份 DB 與同一份 Data Protection certificate因此正式部署順序是先跑 installer migration再啟動 API/Web。
- 檔案型 PFX 設定只作為覆寫或手動輪替用途;若設定了 path檔案必須存在、含 private key 且在有效期內。
- signing、encryption 與 Data Protection 憑證分離;輪替時需保留仍用於驗證舊 token解密舊資料的前一代憑證完成相容輪替後才能移除。
- 前代檔案憑證以 `__Previous__0__Path/Password` 設定,可依序增加 `Previous__1__...`DB-managed 前代輪替管理介面尚未建立。
- 應監控憑證到期日並先在 Stage 驗證DB certificate 缺失、缺 private key 或已過期時應用程式會拒絕啟動。
- 此處監控的是 OpenIddict signing / encryption 與 Data Protection PFX對外 TLS/SSL 憑證若由 AWS ACM 管理,續期與告警由 ACM / AWS 邊界負責。
- `Certificates__ExpirationWarningDays` 預設 `30`(允許 `1180`);進入期限後 API / Web 啟動會寫入 warning logProduction 應以 CloudWatch metric filter / alarm 監控該 warning。

View File

@ -42,16 +42,28 @@ if (string.IsNullOrWhiteSpace(connectionString))
connectionString = "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
}
var requireProductionCertificates = !builder.Environment.IsDevelopment();
var signingCertificate = CertificateLoader.LoadFromConfiguration(
builder.Configuration, "Auth:Certificates:Signing", requireProductionCertificates);
var signingCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync(
builder.Configuration,
connectionString,
"Auth:Certificates:Signing",
CertificateLoader.OpenIddictSigningKey,
requireProductionCertificates);
var previousSigningCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "Auth:Certificates:Signing");
var encryptionCertificate = CertificateLoader.LoadFromConfiguration(
builder.Configuration, "Auth:Certificates:Encryption", requireProductionCertificates);
var encryptionCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync(
builder.Configuration,
connectionString,
"Auth:Certificates:Encryption",
CertificateLoader.OpenIddictEncryptionKey,
requireProductionCertificates);
var previousEncryptionCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "Auth:Certificates:Encryption");
var dataProtectionCertificate = CertificateLoader.LoadFromConfiguration(
builder.Configuration, "DataProtection:Certificate", requireProductionCertificates);
var dataProtectionCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync(
builder.Configuration,
connectionString,
"DataProtection:Certificate",
CertificateLoader.DataProtectionKey,
requireProductionCertificates);
var previousDataProtectionCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "DataProtection:Certificate");
if (!builder.Environment.IsDevelopment() && issuerUri is null)
@ -263,6 +275,7 @@ builder.Services.AddScoped<ITenantManagerService, TenantManagerService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>();
builder.Services.AddScoped<IUserConsentService, UserConsentService>();
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
builder.Services.AddScoped<AdminPermissionService>();
builder.Services.AddScoped<IAdminPermissionChecker>(services => services.GetRequiredService<AdminPermissionService>());

View File

@ -0,0 +1,11 @@
namespace MemberCenter.Application.Abstractions;
public interface IUserConsentService
{
Task RecordRegistrationConsentAsync(
Guid userId,
string registrationMethod,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,7 @@
namespace MemberCenter.Application.Constants;
public static class ConsentVersions
{
public const string TermsVersion = "2026-07-13";
public const string PrivacyVersion = "2026-07-13";
}

View File

@ -0,0 +1,13 @@
namespace MemberCenter.Domain.Entities;
public sealed class UserConsent
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string TermsVersion { get; set; } = string.Empty;
public string PrivacyVersion { get; set; } = string.Empty;
public string RegistrationMethod { get; set; } = string.Empty;
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public DateTimeOffset AcceptedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -1,11 +1,18 @@
using System.Security.Cryptography.X509Certificates;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Configuration;
public static class CertificateLoader
{
public const string OpenIddictSigningKey = "certificates:openiddict:signing";
public const string OpenIddictEncryptionKey = "certificates:openiddict:encryption";
public const string DataProtectionKey = "certificates:data-protection";
public static void LogExpirationWarning(
ILogger logger,
string name,
@ -72,19 +79,161 @@ public static class CertificateLoader
throw new InvalidOperationException($"Certificate configured by {sectionPath} could not be loaded.", ex);
}
ValidateCertificate(certificate, $"configured by {sectionPath}");
return certificate;
}
public static async Task<X509Certificate2?> LoadFromConfigurationOrDatabaseAsync(
IConfiguration configuration,
string connectionString,
string sectionPath,
string systemFlagKey,
bool required,
CancellationToken cancellationToken = default)
{
var configured = LoadFromConfiguration(configuration, sectionPath, required: false);
if (configured is not null)
{
return configured;
}
var stored = await LoadFromDatabaseAsync(connectionString, systemFlagKey, cancellationToken);
if (stored is not null)
{
return stored;
}
if (required)
{
throw new InvalidOperationException(
$"{sectionPath}:Path or DB certificate '{systemFlagKey}' is required outside Development. Run installer init or migrate to provision certificates.");
}
return null;
}
public static async Task<X509Certificate2?> LoadFromDatabaseAsync(
string connectionString,
string systemFlagKey,
CancellationToken cancellationToken = default)
{
await using var db = CreateDbContext(connectionString);
var flag = await db.SystemFlags
.AsNoTracking()
.SingleOrDefaultAsync(item => item.Key == systemFlagKey, cancellationToken);
if (flag is null || string.IsNullOrWhiteSpace(flag.Value))
{
return null;
}
X509Certificate2 certificate;
try
{
certificate = new X509Certificate2(
Convert.FromBase64String(flag.Value),
password: (string?)null,
X509KeyStorageFlags.EphemeralKeySet);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Certificate stored in DB flag '{systemFlagKey}' could not be loaded.", ex);
}
ValidateCertificate(certificate, $"stored in DB flag '{systemFlagKey}'");
return certificate;
}
public static async Task EnsureDatabaseCertificatesAsync(
MemberCenterDbContext db,
CancellationToken cancellationToken = default)
{
await EnsureDatabaseCertificateAsync(
db,
OpenIddictSigningKey,
"membercenter-openiddict-signing",
cancellationToken);
await EnsureDatabaseCertificateAsync(
db,
OpenIddictEncryptionKey,
"membercenter-openiddict-encryption",
cancellationToken);
await EnsureDatabaseCertificateAsync(
db,
DataProtectionKey,
"membercenter-data-protection",
cancellationToken);
}
private static async Task EnsureDatabaseCertificateAsync(
MemberCenterDbContext db,
string key,
string subjectName,
CancellationToken cancellationToken)
{
var flag = await db.SystemFlags.SingleOrDefaultAsync(item => item.Key == key, cancellationToken);
if (flag is not null && !string.IsNullOrWhiteSpace(flag.Value))
{
return;
}
using var rsa = System.Security.Cryptography.RSA.Create(4096);
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest(
$"CN={subjectName}",
rsa,
System.Security.Cryptography.HashAlgorithmName.SHA256,
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
critical: false));
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
using var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddYears(5));
var value = Convert.ToBase64String(certificate.Export(X509ContentType.Pfx));
if (flag is null)
{
db.SystemFlags.Add(new SystemFlag
{
Id = Guid.NewGuid(),
Key = key,
Value = value,
UpdatedAt = DateTimeOffset.UtcNow
});
return;
}
flag.Value = value;
flag.UpdatedAt = DateTimeOffset.UtcNow;
}
private static MemberCenterDbContext CreateDbContext(string connectionString)
{
var options = new DbContextOptionsBuilder<MemberCenterDbContext>()
.UseNpgsql(connectionString)
.UseOpenIddict()
.Options;
return new MemberCenterDbContext(options);
}
private static void ValidateCertificate(X509Certificate2 certificate, string source)
{
var now = DateTime.UtcNow;
if (!certificate.HasPrivateKey)
{
certificate.Dispose();
throw new InvalidOperationException($"Certificate configured by {sectionPath} must contain a private key.");
throw new InvalidOperationException($"Certificate {source} must contain a private key.");
}
if (now < certificate.NotBefore.ToUniversalTime() || now >= certificate.NotAfter.ToUniversalTime())
{
certificate.Dispose();
throw new InvalidOperationException($"Certificate configured by {sectionPath} is not currently valid.");
throw new InvalidOperationException($"Certificate {source} is not currently valid.");
}
return certificate;
}
}

View File

@ -12,6 +12,16 @@ public static class TrustedForwardedHeaders
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
var trustForwardedHeaders = configuration.GetValue("ReverseProxy:TrustForwardedHeaders", false);
if (trustForwardedHeaders)
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost;
options.ForwardLimit = Math.Clamp(configuration.GetValue<int?>("ReverseProxy:ForwardLimit") ?? 1, 1, 5);
return;
}
foreach (var value in Split(configuration["ReverseProxy:KnownProxies"]))
{
if (!IPAddress.TryParse(value, out var address))
@ -39,7 +49,7 @@ public static class TrustedForwardedHeaders
var hasTrustedProxy = options.KnownProxies.Count > 0 || options.KnownNetworks.Count > 0;
options.ForwardedHeaders = hasTrustedProxy
? ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
? ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
: ForwardedHeaders.None;
options.ForwardLimit = Math.Clamp(configuration.GetValue<int?>("ReverseProxy:ForwardLimit") ?? 1, 1, 5);
}

View File

@ -20,6 +20,7 @@ public class MemberCenterDbContext
public DbSet<NewsletterSubscription> NewsletterSubscriptions => Set<NewsletterSubscription>();
public DbSet<UserProfile> UserProfiles => Set<UserProfile>();
public DbSet<UserProfileImage> UserProfileImages => Set<UserProfileImage>();
public DbSet<UserConsent> UserConsents => Set<UserConsent>();
public DbSet<UserAddress> UserAddresses => Set<UserAddress>();
public DbSet<EmailBlacklist> EmailBlacklist => Set<EmailBlacklist>();
public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>();
@ -145,6 +146,23 @@ public class MemberCenterDbContext
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<UserConsent>(entity =>
{
entity.ToTable("user_consents");
entity.HasKey(x => x.Id);
entity.Property(x => x.TermsVersion).IsRequired().HasMaxLength(50);
entity.Property(x => x.PrivacyVersion).IsRequired().HasMaxLength(50);
entity.Property(x => x.RegistrationMethod).IsRequired().HasMaxLength(50);
entity.Property(x => x.IpAddress).HasMaxLength(100);
entity.Property(x => x.UserAgent).HasMaxLength(500);
entity.Property(x => x.AcceptedAt).HasDefaultValueSql("now()");
entity.HasIndex(x => x.UserId).HasDatabaseName("idx_user_consents_user_id");
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<UserAddress>(entity =>
{
entity.ToTable("user_addresses");

View File

@ -0,0 +1,52 @@
using System;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MemberCenterDbContext))]
[Migration("20260713090000_AddUserConsents")]
public partial class AddUserConsents : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "user_consents",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TermsVersion = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
PrivacyVersion = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
RegistrationMethod = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
IpAddress = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
UserAgent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
AcceptedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_user_consents", x => x.Id);
table.ForeignKey(
name: "FK_user_consents_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_user_consents_user_id",
table: "user_consents",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "user_consents");
}
}
}

View File

@ -770,6 +770,51 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.ToTable("user_profile_images", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.UserConsent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("AcceptedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("IpAddress")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PrivacyVersion")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("RegistrationMethod")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TermsVersion")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId")
.HasDatabaseName("idx_user_consents_user_id");
b.ToTable("user_consents", (string)null);
});
modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b =>
{
b.Property<Guid>("Id")
@ -1364,6 +1409,15 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("MemberCenter.Domain.Entities.UserConsent", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)

View File

@ -0,0 +1,60 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
namespace MemberCenter.Infrastructure.Services;
public sealed class UserConsentService : IUserConsentService
{
private readonly MemberCenterDbContext _dbContext;
private readonly IAuditLogWriter _auditLogWriter;
public UserConsentService(MemberCenterDbContext dbContext, IAuditLogWriter auditLogWriter)
{
_dbContext = dbContext;
_auditLogWriter = auditLogWriter;
}
public async Task RecordRegistrationConsentAsync(
Guid userId,
string registrationMethod,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken = default)
{
var consent = new UserConsent
{
Id = Guid.NewGuid(),
UserId = userId,
TermsVersion = ConsentVersions.TermsVersion,
PrivacyVersion = ConsentVersions.PrivacyVersion,
RegistrationMethod = registrationMethod,
IpAddress = Truncate(ipAddress, 100),
UserAgent = Truncate(userAgent, 500),
AcceptedAt = DateTimeOffset.UtcNow
};
_dbContext.UserConsents.Add(consent);
await _dbContext.SaveChangesAsync(cancellationToken);
await _auditLogWriter.WriteAsync("user", userId, "account.consent_accepted", new
{
user_id = userId,
terms_version = consent.TermsVersion,
privacy_version = consent.PrivacyVersion,
registration_method = consent.RegistrationMethod
});
}
private static string? Truncate(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
value = value.Trim();
return value.Length <= maxLength ? value : value[..maxLength];
}
}

View File

@ -102,6 +102,8 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
}
await db.Database.MigrateAsync();
await CertificateLoader.EnsureDatabaseCertificatesAsync(db);
await db.SaveChangesAsync();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
@ -274,6 +276,8 @@ migrateCommand.SetHandler(async (string? connectionString, string? appsettings,
if (string.IsNullOrWhiteSpace(target))
{
await db.Database.MigrateAsync();
await CertificateLoader.EnsureDatabaseCertificatesAsync(db);
await db.SaveChangesAsync();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
@ -302,6 +306,9 @@ syncOAuthClientsCommand.SetHandler(async (string? connectionString, string? apps
var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
await CertificateLoader.EnsureDatabaseCertificatesAsync(db);
await db.SaveChangesAsync();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();

View File

@ -3,12 +3,14 @@ using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Web.Localization;
using MemberCenter.Web.Models.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Localization;
namespace MemberCenter.Web.Controllers;
@ -16,31 +18,37 @@ public class AccountController : Controller
{
private readonly IAccountProvisioningService _accountProvisioningService;
private readonly IAccountEmailService _accountEmailService;
private readonly IUserConsentService _userConsentService;
private readonly IAuditLogWriter _auditLogWriter;
private readonly IConfiguration _configuration;
private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;
private readonly bool _allowInsecureReturnUrls;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IStringLocalizer<SharedResource> _localizer;
public AccountController(
IAccountProvisioningService accountProvisioningService,
IAccountEmailService accountEmailService,
IUserConsentService userConsentService,
IAuditLogWriter auditLogWriter,
IConfiguration configuration,
IAuthenticationSchemeProvider authenticationSchemeProvider,
IWebHostEnvironment environment,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
SignInManager<ApplicationUser> signInManager,
IStringLocalizer<SharedResource> localizer)
{
_accountProvisioningService = accountProvisioningService;
_accountEmailService = accountEmailService;
_userConsentService = userConsentService;
_auditLogWriter = auditLogWriter;
_configuration = configuration;
_authenticationSchemeProvider = authenticationSchemeProvider;
_allowInsecureReturnUrls = environment.IsDevelopment();
_userManager = userManager;
_signInManager = signInManager;
_localizer = localizer;
}
[HttpGet]
@ -65,7 +73,7 @@ public class AccountController : Controller
var loginUser = await _userManager.FindByEmailAsync(model.Email);
if (loginUser?.DisabledAt.HasValue == true)
{
ModelState.AddModelError(string.Empty, "Account is disabled.");
AddLocalizedModelError("Account is disabled.");
await SetExternalLoginAvailabilityAsync();
return View(model);
}
@ -75,12 +83,12 @@ public class AccountController : Controller
{
if (result.IsLockedOut)
{
ModelState.AddModelError(string.Empty, "Account is temporarily locked. Please try again later.");
AddLocalizedModelError("Account is temporarily locked. Please try again later.");
await SetExternalLoginAvailabilityAsync();
return View(model);
}
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
AddLocalizedModelError("Invalid login attempt.");
await SetExternalLoginAvailabilityAsync();
return View(model);
}
@ -105,7 +113,7 @@ public class AccountController : Controller
{
if (await _authenticationSchemeProvider.GetSchemeAsync(provider) is null)
{
ModelState.AddModelError(string.Empty, $"{provider} login is not configured.");
AddLocalizedModelError("{0} login is not configured.", provider);
await SetExternalLoginAvailabilityAsync();
return View("Login", new LoginViewModel { ReturnUrl = returnUrl, RememberMe = rememberMe });
}
@ -124,7 +132,7 @@ public class AccountController : Controller
{
if (!string.IsNullOrWhiteSpace(remoteError))
{
ModelState.AddModelError(string.Empty, $"External login failed: {remoteError}");
AddLocalizedModelError("External login failed: {0}", remoteError);
await SetExternalLoginAvailabilityAsync();
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
}
@ -132,13 +140,25 @@ public class AccountController : Controller
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info is null)
{
ModelState.AddModelError(string.Empty, "Unable to load external login information.");
AddLocalizedModelError("Unable to load external login information.");
await SetExternalLoginAvailabilityAsync();
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
}
var email = info.Principal.FindFirstValue(ClaimTypes.Email) ?? info.Principal.FindFirstValue("email");
var emailVerified = bool.TryParse(info.Principal.FindFirstValue("email_verified"), out var parsed) && parsed;
var existingExternalUser = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
if (existingExternalUser is null)
{
return View("ExternalRegisterConfirmation", new ExternalRegisterConfirmationViewModel
{
Provider = info.LoginProvider,
Email = email,
ReturnUrl = returnUrl,
RememberMe = rememberMe
});
}
var result = await _accountProvisioningService.ProvisionExternalLoginAsync(
info.LoginProvider,
info.ProviderKey,
@ -149,7 +169,7 @@ public class AccountController : Controller
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error);
AddLocalizedModelError(error);
}
await SetExternalLoginAvailabilityAsync();
@ -159,14 +179,14 @@ public class AccountController : Controller
var user = await _userManager.FindByIdAsync(result.UserId.Value.ToString());
if (user is null)
{
ModelState.AddModelError(string.Empty, "Unable to locate the linked account.");
AddLocalizedModelError("Unable to locate the linked account.");
await SetExternalLoginAvailabilityAsync();
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
}
if (user.DisabledAt.HasValue)
{
ModelState.AddModelError(string.Empty, "Account is disabled.");
AddLocalizedModelError("Account is disabled.");
await SetExternalLoginAvailabilityAsync();
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
}
@ -182,6 +202,76 @@ public class AccountController : Controller
return RedirectToAction("Index", "Home", new { area = string.Empty });
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)]
public async Task<IActionResult> ExternalRegisterConfirmation(ExternalRegisterConfirmationViewModel model)
{
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info is null)
{
AddLocalizedModelError("Unable to load external login information.");
await SetExternalLoginAvailabilityAsync();
return View("Login", new LoginViewModel { ReturnUrl = model.ReturnUrl, RememberMe = model.RememberMe });
}
model.Provider = info.LoginProvider;
model.Email = info.Principal.FindFirstValue(ClaimTypes.Email) ?? info.Principal.FindFirstValue("email");
if (!ModelState.IsValid)
{
return View(model);
}
var emailVerified = bool.TryParse(info.Principal.FindFirstValue("email_verified"), out var parsed) && parsed;
var result = await _accountProvisioningService.ProvisionExternalLoginAsync(
info.LoginProvider,
info.ProviderKey,
model.Email,
emailVerified);
if (!result.Succeeded || result.UserId is null)
{
foreach (var error in result.Errors)
{
AddLocalizedModelError(error);
}
return View(model);
}
await _userConsentService.RecordRegistrationConsentAsync(
result.UserId.Value,
"google",
GetRequestIpAddress(),
Request.Headers.UserAgent.ToString());
var user = await _userManager.FindByIdAsync(result.UserId.Value.ToString());
if (user is null)
{
AddLocalizedModelError("Unable to locate the linked account.");
await SetExternalLoginAvailabilityAsync();
return View("Login", new LoginViewModel { ReturnUrl = model.ReturnUrl, RememberMe = model.RememberMe });
}
if (user.DisabledAt.HasValue)
{
AddLocalizedModelError("Account is disabled.");
await SetExternalLoginAvailabilityAsync();
return View("Login", new LoginViewModel { ReturnUrl = model.ReturnUrl, RememberMe = model.RememberMe });
}
await _signInManager.SignInAsync(user, model.RememberMe, info.LoginProvider);
await UpdateSignInMetadataAsync(user);
if (IsAllowedReturnUrl(model.ReturnUrl, ReturnUrlPurpose.Login))
{
return Redirect(model.ReturnUrl!);
}
return RedirectToAction("Index", "Home", new { area = string.Empty });
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> Logout(string? returnUrl = null)
@ -242,7 +332,7 @@ public class AccountController : Controller
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
AddLocalizedModelError(error.Description);
}
return View(model);
@ -283,7 +373,7 @@ public class AccountController : Controller
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error);
AddLocalizedModelError(error);
}
await SetExternalLoginAvailabilityAsync();
return View(model);
@ -292,6 +382,11 @@ public class AccountController : Controller
var user = await _userManager.FindByEmailAsync(model.Email);
if (user is not null)
{
await _userConsentService.RecordRegistrationConsentAsync(
user.Id,
"local",
GetRequestIpAddress(),
Request.Headers.UserAgent.ToString());
await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl());
}
@ -353,7 +448,7 @@ public class AccountController : Controller
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
AddLocalizedModelError(error.Description);
}
return View(model);
}
@ -412,6 +507,16 @@ public class AccountController : Controller
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
private void AddLocalizedModelError(string message, params object[] arguments)
{
ModelState.AddModelError(string.Empty, _localizer[message, arguments]);
}
private string? GetRequestIpAddress()
{
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private async Task SetExternalLoginAvailabilityAsync()
{
ViewData["GoogleLoginEnabled"] = await _authenticationSchemeProvider.GetSchemeAsync("Google") is not null;

View File

@ -4,16 +4,19 @@ namespace MemberCenter.Web.Models.Account;
public sealed class ChangePasswordViewModel
{
[Required]
[Display(Name = "CurrentPassword")]
[Required(ErrorMessage = "The {0} field is required.")]
[DataType(DataType.Password)]
public string CurrentPassword { get; set; } = string.Empty;
[Required]
[Display(Name = "NewPassword")]
[Required(ErrorMessage = "The {0} field is required.")]
[DataType(DataType.Password)]
public string NewPassword { get; set; } = string.Empty;
[Required]
[Compare(nameof(NewPassword))]
[Display(Name = "ConfirmPassword")]
[Required(ErrorMessage = "The {0} field is required.")]
[Compare(nameof(NewPassword), ErrorMessage = "The {0} and {1} fields do not match.")]
[DataType(DataType.Password)]
public string ConfirmPassword { get; set; } = string.Empty;
}

View File

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Account;
public sealed class ExternalRegisterConfirmationViewModel
{
public string Provider { get; set; } = string.Empty;
public string? Email { get; set; }
public string? ReturnUrl { get; set; }
public bool RememberMe { get; set; }
[Display(Name = "AcceptTerms")]
[Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms before registering.")]
public bool AcceptTerms { get; set; }
}

View File

@ -4,7 +4,8 @@ namespace MemberCenter.Web.Models.Account;
public sealed class ForgotPasswordViewModel
{
[Required]
[EmailAddress]
[Display(Name = "Email")]
[Required(ErrorMessage = "The {0} field is required.")]
[EmailAddress(ErrorMessage = "The {0} field is not a valid e-mail address.")]
public string Email { get; set; } = string.Empty;
}

View File

@ -4,11 +4,13 @@ namespace MemberCenter.Web.Models.Account;
public sealed class LoginViewModel
{
[Required]
[EmailAddress]
[Display(Name = "Email")]
[Required(ErrorMessage = "The {0} field is required.")]
[EmailAddress(ErrorMessage = "The {0} field is not a valid e-mail address.")]
public string Email { get; set; } = string.Empty;
[Required]
[Display(Name = "Password")]
[Required(ErrorMessage = "The {0} field is required.")]
[DataType(DataType.Password)]
public string Password { get; set; } = string.Empty;

View File

@ -4,16 +4,19 @@ namespace MemberCenter.Web.Models.Account;
public sealed class RegisterViewModel
{
[Required]
[EmailAddress]
[Display(Name = "Email")]
[Required(ErrorMessage = "The {0} field is required.")]
[EmailAddress(ErrorMessage = "The {0} field is not a valid e-mail address.")]
public string Email { get; set; } = string.Empty;
[Required]
[Display(Name = "Password")]
[Required(ErrorMessage = "The {0} field is required.")]
[DataType(DataType.Password)]
public string Password { get; set; } = string.Empty;
[Required]
[Compare(nameof(Password))]
[Display(Name = "ConfirmPassword")]
[Required(ErrorMessage = "The {0} field is required.")]
[Compare(nameof(Password), ErrorMessage = "The {0} and {1} fields do not match.")]
[DataType(DataType.Password)]
public string ConfirmPassword { get; set; } = string.Empty;

View File

@ -4,19 +4,23 @@ namespace MemberCenter.Web.Models.Account;
public sealed class ResetPasswordViewModel
{
[Required]
[EmailAddress]
[Display(Name = "Email")]
[Required(ErrorMessage = "The {0} field is required.")]
[EmailAddress(ErrorMessage = "The {0} field is not a valid e-mail address.")]
public string Email { get; set; } = string.Empty;
[Required]
[Display(Name = "Token")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Token { get; set; } = string.Empty;
[Required]
[Display(Name = "NewPassword")]
[Required(ErrorMessage = "The {0} field is required.")]
[DataType(DataType.Password)]
public string NewPassword { get; set; } = string.Empty;
[Required]
[Compare(nameof(NewPassword))]
[Display(Name = "ConfirmPassword")]
[Required(ErrorMessage = "The {0} field is required.")]
[Compare(nameof(NewPassword), ErrorMessage = "The {0} and {1} fields do not match.")]
[DataType(DataType.Password)]
public string ConfirmPassword { get; set; } = string.Empty;
}

View File

@ -4,10 +4,12 @@ namespace MemberCenter.Web.Models.Admin;
public sealed class EmailBlacklistFormViewModel
{
[Required]
[EmailAddress]
[Display(Name = "Email")]
[Required(ErrorMessage = "The {0} field is required.")]
[EmailAddress(ErrorMessage = "The {0} field is not a valid e-mail address.")]
public string Email { get; set; } = string.Empty;
[Required]
[Display(Name = "Reason")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Reason { get; set; } = string.Empty;
}

View File

@ -6,15 +6,18 @@ public sealed class NewsletterListFormViewModel
{
public Guid? Id { get; set; }
[Required]
[Display(Name = "Tenant")]
[Required(ErrorMessage = "The {0} field is required.")]
public Guid TenantId { get; set; }
public IReadOnlyList<MemberCenter.Application.Models.Admin.TenantDto> Tenants { get; set; }
= Array.Empty<MemberCenter.Application.Models.Admin.TenantDto>();
[Required]
[Display(Name = "Name")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Name { get; set; } = string.Empty;
[Required]
[Display(Name = "Status")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Status { get; set; } = "active";
}

View File

@ -6,13 +6,16 @@ public sealed class OAuthClientFormViewModel
{
public Guid? TenantId { get; set; }
[Required]
[Display(Name = "Name")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Name { get; set; } = string.Empty;
[Required]
[Display(Name = "Type")]
[Required(ErrorMessage = "The {0} field is required.")]
public string ClientType { get; set; } = "public";
[Required]
[Display(Name = "Usage")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Usage { get; set; } = "tenant_api";
public string RedirectUris { get; set; } = string.Empty;

View File

@ -6,12 +6,14 @@ public sealed class TenantFormViewModel
{
public Guid? Id { get; set; }
[Required]
[Display(Name = "Name")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Name { get; set; } = string.Empty;
public string Domains { get; set; } = string.Empty;
[Required]
[Display(Name = "Status")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Status { get; set; } = "active";
public string? SendEngineWebhookClientId { get; set; }

View File

@ -4,6 +4,7 @@ namespace MemberCenter.Web.Models.Newsletter;
public sealed class ConfirmViewModel
{
[Required]
[Display(Name = "Token")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Token { get; set; } = string.Empty;
}

View File

@ -4,6 +4,7 @@ namespace MemberCenter.Web.Models.Newsletter;
public sealed class UnsubscribeViewModel
{
[Required]
[Display(Name = "Token")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Token { get; set; } = string.Empty;
}

View File

@ -6,42 +6,53 @@ public sealed class AddressFormViewModel
{
public Guid? Id { get; set; }
[Required]
[StringLength(100)]
[Display(Name = "Label")]
[Required(ErrorMessage = "The {0} field is required.")]
[StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string Label { get; set; } = "home";
[Required]
[StringLength(100)]
[Display(Name = "Recipient name")]
[Required(ErrorMessage = "The {0} field is required.")]
[StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string RecipientName { get; set; } = string.Empty;
[Required]
[StringLength(50)]
[Display(Name = "Recipient phone")]
[Required(ErrorMessage = "The {0} field is required.")]
[StringLength(50, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string RecipientPhone { get; set; } = string.Empty;
[Required]
[StringLength(2, MinimumLength = 2)]
[Display(Name = "Country code")]
[Required(ErrorMessage = "The {0} field is required.")]
[StringLength(2, MinimumLength = 2, ErrorMessage = "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.")]
public string CountryCode { get; set; } = "TW";
[StringLength(20)]
[Display(Name = "Postal code")]
[StringLength(20, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? PostalCode { get; set; }
[StringLength(100)]
[Display(Name = "State / region")]
[StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? StateRegion { get; set; }
[StringLength(100)]
[Display(Name = "City")]
[StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? City { get; set; }
[StringLength(100)]
[Display(Name = "District")]
[StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? District { get; set; }
[Required]
[StringLength(255)]
[Display(Name = "Address line 1")]
[Required(ErrorMessage = "The {0} field is required.")]
[StringLength(255, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string AddressLine1 { get; set; } = string.Empty;
[StringLength(255)]
[Display(Name = "Address line 2")]
[StringLength(255, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? AddressLine2 { get; set; }
[StringLength(200)]
[Display(Name = "Company name")]
[StringLength(200, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? CompanyName { get; set; }
public bool IsDefault { get; set; }

View File

@ -4,48 +4,62 @@ namespace MemberCenter.Web.Models.Profile;
public sealed class ProfileViewModel
{
[Required]
[StringLength(100)]
[Display(Name = "Last name")]
[Required(ErrorMessage = "The {0} field is required.")]
[StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string LastName { get; set; } = string.Empty;
[Required]
[StringLength(100)]
[Display(Name = "First name")]
[Required(ErrorMessage = "The {0} field is required.")]
[StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string FirstName { get; set; } = string.Empty;
[StringLength(100)]
[Display(Name = "Nickname")]
[StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? NickName { get; set; }
[StringLength(50)]
[Display(Name = "Mobile phone")]
[StringLength(50, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? MobilePhone { get; set; }
[StringLength(50)]
[Display(Name = "Landline phone")]
[StringLength(50, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? LandlinePhone { get; set; }
[Display(Name = "Date of birth")]
[DataType(DataType.Date)]
public DateOnly? DateOfBirth { get; set; }
[Required]
[Display(Name = "Gender")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Gender { get; set; } = "unspecified";
[StringLength(200)]
[Display(Name = "Company name")]
[StringLength(200, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? CompanyName { get; set; }
[StringLength(200)]
[Display(Name = "Department")]
[StringLength(200, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? Department { get; set; }
[StringLength(200)]
[Display(Name = "Job title")]
[StringLength(200, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? JobTitle { get; set; }
[StringLength(50)]
[Display(Name = "Company phone")]
[StringLength(50, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? CompanyPhone { get; set; }
[StringLength(32)]
[Display(Name = "Tax ID")]
[StringLength(32, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? TaxId { get; set; }
[StringLength(200)]
[Display(Name = "Invoice title")]
[StringLength(200, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? InvoiceTitle { get; set; }
[StringLength(1000)]
[Display(Name = "Remark")]
[StringLength(1000, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")]
public string? Remark { get; set; }
public string Email { get; set; } = string.Empty;

View File

@ -15,6 +15,7 @@ using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using MemberCenter.Infrastructure.Services;
using MemberCenter.Web.Localization;
using MemberCenter.Web.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@ -38,8 +39,12 @@ if (string.IsNullOrWhiteSpace(connectionString))
connectionString = "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
}
var dataProtectionCertificate = CertificateLoader.LoadFromConfiguration(
builder.Configuration, "DataProtection:Certificate", required: !builder.Environment.IsDevelopment());
var dataProtectionCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync(
builder.Configuration,
connectionString,
"DataProtection:Certificate",
CertificateLoader.DataProtectionKey,
required: !builder.Environment.IsDevelopment());
var previousDataProtectionCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "DataProtection:Certificate");
@ -76,6 +81,7 @@ builder.Services
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(identitySecurity.Lockout.DefaultLockoutMinutes);
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddErrorDescriber<LocalizedIdentityErrorDescriber>()
.AddDefaultTokenProviders();
builder.Services.Configure<SecurityStampValidatorOptions>(options =>
@ -87,14 +93,18 @@ var googleClientId = builder.Configuration["Authentication:Google:ClientId"]
?? Environment.GetEnvironmentVariable("Authentication__Google__ClientId");
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"]
?? Environment.GetEnvironmentVariable("Authentication__Google__ClientSecret");
var googleLoginEnabled = false;
var authenticationBuilder = builder.Services.AddAuthentication();
if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
{
googleLoginEnabled = true;
var configuredGoogleClientId = googleClientId;
var configuredGoogleClientSecret = googleClientSecret;
authenticationBuilder.AddGoogle(options =>
{
options.ClientId = googleClientId;
options.ClientSecret = googleClientSecret;
options.ClientId = configuredGoogleClientId;
options.ClientSecret = configuredGoogleClientSecret;
});
}
@ -167,6 +177,7 @@ builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>();
builder.Services.AddScoped<IUserConsentService, UserConsentService>();
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
builder.Services.AddScoped<AdminPermissionService>();
builder.Services.AddScoped<IAdminPermissionChecker>(services => services.GetRequiredService<AdminPermissionService>());
@ -191,7 +202,10 @@ builder.Services.AddControllersWithViews(options =>
options.Filters.Add(new Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute());
})
.AddViewLocalization()
.AddDataAnnotationsLocalization();
.AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (_, factory) => factory.Create(typeof(SharedResource));
});
builder.Services.AddHttpContextAccessor();
var supportedCultures = new[]
@ -232,8 +246,11 @@ app.Use(async (context, next) =>
context.Response.OnStarting(() =>
{
var headers = context.Response.Headers;
var formAction = googleLoginEnabled
? "'self' https://accounts.google.com"
: "'self'";
headers.TryAdd("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
$"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action {formAction}; frame-ancestors 'none'");
headers.TryAdd("X-Content-Type-Options", "nosniff");
headers.TryAdd("X-Frame-Options", "DENY");
headers.TryAdd("Referrer-Policy", "no-referrer");

View File

@ -35,6 +35,7 @@
<data name="Create" xml:space="preserve"><value>建立</value></data>
<data name="Create account" xml:space="preserve"><value>建立新帳號</value></data>
<data name="Create your member account with email." xml:space="preserve"><value>使用 Email 建立會員帳號。</value></data>
<data name="Complete registration" xml:space="preserve"><value>完成註冊</value></data>
<data name="Current count" xml:space="preserve"><value>現人數</value></data>
<data name="Current total" xml:space="preserve"><value>現總數</value></data>
<data name="Dashboard summary" xml:space="preserve"><value>儀表板摘要</value></data>
@ -48,6 +49,7 @@
<data name="Enter your email" xml:space="preserve"><value>輸入你的 Email</value></data>
<data name="Enter your password" xml:space="preserve"><value>輸入你的密碼</value></data>
<data name="Enter your password again" xml:space="preserve"><value>再次輸入你的密碼</value></data>
<data name="Email unavailable" xml:space="preserve"><value>Email 無法取得</value></data>
<data name="Edit Newsletter" xml:space="preserve"><value>編輯電子報</value></data>
<data name="Edit OAuth Client" xml:space="preserve"><value>編輯 OAuth Client</value></data>
<data name="Edit Tenant" xml:space="preserve"><value>編輯租戶</value></data>
@ -58,6 +60,7 @@
<data name="Freeze account" xml:space="preserve"><value>帳號凍結</value></data>
<data name="Frozen" xml:space="preserve"><value>凍結</value></data>
<data name="Gender" xml:space="preserve"><value>性別</value></data>
<data name="Google account" xml:space="preserve"><value>Google 帳號</value></data>
<data name="Home" xml:space="preserve"><value>主頁</value></data>
<data name="I have read and agree to the" xml:space="preserve"><value>我已閱讀並同意</value></data>
<data name="Language" xml:space="preserve"><value>語言</value></data>
@ -99,6 +102,7 @@
<data name="Remember me" xml:space="preserve"><value>記住我</value></data>
<data name="Register" xml:space="preserve"><value>註冊</value></data>
<data name="Register with Google" xml:space="preserve"><value>使用 Google 註冊</value></data>
<data name="Review and accept the terms to create your account." xml:space="preserve"><value>請確認並同意條款後建立帳號。</value></data>
<data name="Resend verification email" xml:space="preserve"><value>重新寄送驗證信</value></data>
<data name="Save" xml:space="preserve"><value>儲存</value></data>
<data name="Search" xml:space="preserve"><value>搜尋</value></data>
@ -263,4 +267,53 @@
<data name="unspecified" xml:space="preserve"><value>未指定</value></data>
<data name="Webhook Client ID" xml:space="preserve"><value>Webhook Client ID</value></data>
<data name="Zoom" xml:space="preserve"><value>縮放</value></data>
<data name="AcceptTerms" xml:space="preserve"><value>服務條款與隱私權政策</value></data>
<data name="ConfirmPassword" xml:space="preserve"><value>確認密碼</value></data>
<data name="CurrentPassword" xml:space="preserve"><value>目前密碼</value></data>
<data name="NewPassword" xml:space="preserve"><value>新密碼</value></data>
<data name="ReturnUrl" xml:space="preserve"><value>返回網址</value></data>
<data name="RememberMe" xml:space="preserve"><value>記住我</value></data>
<data name="The {0} field is required." xml:space="preserve"><value>{0} 欄位為必填。</value></data>
<data name="The {0} field is not a valid e-mail address." xml:space="preserve"><value>{0} 必須是有效的 Email。</value></data>
<data name="The {0} field is not a valid email address." xml:space="preserve"><value>{0} 必須是有效的 Email。</value></data>
<data name="The {0} and {1} fields do not match." xml:space="preserve"><value>{0} 與 {1} 不相符。</value></data>
<data name="'{0}' and '{1}' do not match." xml:space="preserve"><value>{0} 與 {1} 不相符。</value></data>
<data name="The field {0} must be a string with a maximum length of {1}." xml:space="preserve"><value>{0} 不可超過 {1} 個字元。</value></data>
<data name="The field {0} must be a string with a minimum length of {2} and a maximum length of {1}." xml:space="preserve"><value>{0} 長度必須介於 {2} 到 {1} 個字元。</value></data>
<data name="The field {0} must be between {1} and {2}." xml:space="preserve"><value>{0} 必須介於 {1} 到 {2} 之間。</value></data>
<data name="The value '{0}' is invalid." xml:space="preserve"><value>{0} 的值無效。</value></data>
<data name="The supplied value is invalid for {0}." xml:space="preserve"><value>{0} 的值無效。</value></data>
<data name="A value for the '{0}' parameter or property was not provided." xml:space="preserve"><value>請提供 {0}。</value></data>
<data name="The value '{0}' is not valid for {1}." xml:space="preserve"><value>{1} 的值「{0}」無效。</value></data>
<data name="You must accept the terms before registering." xml:space="preserve"><value>註冊前必須同意服務條款與隱私權政策。</value></data>
<data name="Account is disabled." xml:space="preserve"><value>帳號已停用。</value></data>
<data name="Account is temporarily locked. Please try again later." xml:space="preserve"><value>帳號暫時鎖定,請稍後再試。</value></data>
<data name="Invalid login attempt." xml:space="preserve"><value>登入失敗,請確認 Email 與密碼。</value></data>
<data name="{0} login is not configured." xml:space="preserve"><value>{0} 登入尚未設定。</value></data>
<data name="External login failed: {0}" xml:space="preserve"><value>外部登入失敗:{0}</value></data>
<data name="Unable to load external login information." xml:space="preserve"><value>無法取得外部登入資訊。</value></data>
<data name="Unable to locate the linked account." xml:space="preserve"><value>找不到已連結的帳號。</value></data>
<data name="External login did not provide an email address." xml:space="preserve"><value>外部登入未提供 Email。</value></data>
<data name="An unknown failure has occurred." xml:space="preserve"><value>發生未知錯誤。</value></data>
<data name="Optimistic concurrency failure, object has been modified." xml:space="preserve"><value>資料已被其他操作修改,請重新整理後再試。</value></data>
<data name="Incorrect password." xml:space="preserve"><value>密碼不正確。</value></data>
<data name="Invalid token." xml:space="preserve"><value>Token 無效。</value></data>
<data name="A user with this login already exists." xml:space="preserve"><value>這個登入方式已被其他帳號使用。</value></data>
<data name="User name '{0}' is invalid, can only contain letters or digits." xml:space="preserve"><value>使用者名稱「{0}」無效,只能包含英文字母或數字。</value></data>
<data name="Email '{0}' is invalid." xml:space="preserve"><value>Email「{0}」無效。</value></data>
<data name="User name '{0}' is already taken." xml:space="preserve"><value>使用者名稱「{0}」已被使用。</value></data>
<data name="Email '{0}' is already taken." xml:space="preserve"><value>Email「{0}」已被使用。</value></data>
<data name="Role name '{0}' is invalid." xml:space="preserve"><value>角色名稱「{0}」無效。</value></data>
<data name="Role name '{0}' is already taken." xml:space="preserve"><value>角色名稱「{0}」已被使用。</value></data>
<data name="User already has a password set." xml:space="preserve"><value>使用者已設定密碼。</value></data>
<data name="Lockout is not enabled for this user." xml:space="preserve"><value>這個使用者未啟用鎖定功能。</value></data>
<data name="User already in role '{0}'." xml:space="preserve"><value>使用者已在「{0}」角色中。</value></data>
<data name="User is not in role '{0}'." xml:space="preserve"><value>使用者不在「{0}」角色中。</value></data>
<data name="Passwords must be at least {0} characters." xml:space="preserve"><value>密碼至少需要 {0} 個字元。</value></data>
<data name="Passwords must use at least {0} different characters." xml:space="preserve"><value>密碼至少需要使用 {0} 種不同字元。</value></data>
<data name="Passwords must have at least one non alphanumeric character." xml:space="preserve"><value>密碼至少需要一個非英數字元。</value></data>
<data name="Passwords must have at least one digit ('0'-'9')." xml:space="preserve"><value>密碼至少需要一個數字0-9。</value></data>
<data name="Passwords must have at least one lowercase ('a'-'z')." xml:space="preserve"><value>密碼至少需要一個小寫英文字母a-z。</value></data>
<data name="Passwords must have at least one uppercase ('A'-'Z')." xml:space="preserve"><value>密碼至少需要一個大寫英文字母A-Z。</value></data>
<data name="Recovery code redemption failed." xml:space="preserve"><value>復原碼驗證失敗。</value></data>
</root>

View File

@ -0,0 +1,88 @@
using MemberCenter.Web.Localization;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Localization;
namespace MemberCenter.Web.Services;
public sealed class LocalizedIdentityErrorDescriber : IdentityErrorDescriber
{
private readonly IStringLocalizer<SharedResource> _localizer;
public LocalizedIdentityErrorDescriber(IStringLocalizer<SharedResource> localizer)
{
_localizer = localizer;
}
public override IdentityError DefaultError() =>
Error(nameof(DefaultError), "An unknown failure has occurred.");
public override IdentityError ConcurrencyFailure() =>
Error(nameof(ConcurrencyFailure), "Optimistic concurrency failure, object has been modified.");
public override IdentityError PasswordMismatch() =>
Error(nameof(PasswordMismatch), "Incorrect password.");
public override IdentityError InvalidToken() =>
Error(nameof(InvalidToken), "Invalid token.");
public override IdentityError LoginAlreadyAssociated() =>
Error(nameof(LoginAlreadyAssociated), "A user with this login already exists.");
public override IdentityError InvalidUserName(string? userName) =>
Error(nameof(InvalidUserName), "User name '{0}' is invalid, can only contain letters or digits.", userName ?? string.Empty);
public override IdentityError InvalidEmail(string? email) =>
Error(nameof(InvalidEmail), "Email '{0}' is invalid.", email ?? string.Empty);
public override IdentityError DuplicateUserName(string userName) =>
Error(nameof(DuplicateUserName), "User name '{0}' is already taken.", userName);
public override IdentityError DuplicateEmail(string email) =>
Error(nameof(DuplicateEmail), "Email '{0}' is already taken.", email);
public override IdentityError InvalidRoleName(string? role) =>
Error(nameof(InvalidRoleName), "Role name '{0}' is invalid.", role ?? string.Empty);
public override IdentityError DuplicateRoleName(string role) =>
Error(nameof(DuplicateRoleName), "Role name '{0}' is already taken.", role);
public override IdentityError UserAlreadyHasPassword() =>
Error(nameof(UserAlreadyHasPassword), "User already has a password set.");
public override IdentityError UserLockoutNotEnabled() =>
Error(nameof(UserLockoutNotEnabled), "Lockout is not enabled for this user.");
public override IdentityError UserAlreadyInRole(string role) =>
Error(nameof(UserAlreadyInRole), "User already in role '{0}'.", role);
public override IdentityError UserNotInRole(string role) =>
Error(nameof(UserNotInRole), "User is not in role '{0}'.", role);
public override IdentityError PasswordTooShort(int length) =>
Error(nameof(PasswordTooShort), "Passwords must be at least {0} characters.", length);
public override IdentityError PasswordRequiresUniqueChars(int uniqueChars) =>
Error(nameof(PasswordRequiresUniqueChars), "Passwords must use at least {0} different characters.", uniqueChars);
public override IdentityError PasswordRequiresNonAlphanumeric() =>
Error(nameof(PasswordRequiresNonAlphanumeric), "Passwords must have at least one non alphanumeric character.");
public override IdentityError PasswordRequiresDigit() =>
Error(nameof(PasswordRequiresDigit), "Passwords must have at least one digit ('0'-'9').");
public override IdentityError PasswordRequiresLower() =>
Error(nameof(PasswordRequiresLower), "Passwords must have at least one lowercase ('a'-'z').");
public override IdentityError PasswordRequiresUpper() =>
Error(nameof(PasswordRequiresUpper), "Passwords must have at least one uppercase ('A'-'Z').");
public override IdentityError RecoveryCodeRedemptionFailed() =>
Error(nameof(RecoveryCodeRedemptionFailed), "Recovery code redemption failed.");
private IdentityError Error(string code, string message, params object[] arguments) =>
new()
{
Code = code,
Description = _localizer[message, arguments]
};
}

View File

@ -0,0 +1,40 @@
@model MemberCenter.Web.Models.Account.ExternalRegisterConfirmationViewModel
@{
Layout = "_AuthLayout";
ViewData["Title"] = L["Complete registration"];
}
<div class="auth-heading-row">
<div>
<h1>@L["Complete registration"]</h1>
<p>@L["Review and accept the terms to create your account."]</p>
</div>
<a asp-area="" asp-controller="Account" asp-action="Login">@L["Back to login"]</a>
</div>
<div asp-validation-summary="All"></div>
<form class="auth-form" method="post" asp-area="" asp-controller="Account" asp-action="ExternalRegisterConfirmation">
<input type="hidden" asp-for="Provider" />
<input type="hidden" asp-for="Email" />
<input type="hidden" asp-for="ReturnUrl" />
<input type="hidden" asp-for="RememberMe" />
<div class="auth-external-account">
<span>@L["Google account"]</span>
<strong>@(Model.Email ?? L["Email unavailable"].Value)</strong>
</div>
<label class="auth-terms">
<input asp-for="AcceptTerms" />
<span>
@L["I have read and agree to the"]
<a asp-area="" asp-controller="Home" asp-action="Terms" target="_blank" rel="noopener">@L["Terms of Service"]</a>
@L["and"]
<a asp-area="" asp-controller="Home" asp-action="Privacy" target="_blank" rel="noopener">@L["Privacy Policy"]</a>.
</span>
</label>
<span asp-validation-for="AcceptTerms"></span>
<button type="submit" class="auth-submit-button">@L["Create account"]</button>
</form>

View File

@ -5,3 +5,4 @@
<h1>@L["Password Reset"]</h1>
<p>@L["If the email exists, a password reset email has been sent."]</p>
<a class="auth-submit-button" asp-area="" asp-controller="Account" asp-action="Login">@L["Back to login"]</a>

View File

@ -385,6 +385,20 @@ span.field-validation-error {
gap: 0.6rem;
}
.auth-external-account {
display: grid;
gap: 0.25rem;
padding: 0.75rem 0;
border-top: 1px solid #c9c7c1;
border-bottom: 1px solid #c9c7c1;
color: #111827;
font-size: 0.78rem;
}
.auth-external-account strong {
font-size: 0.95rem;
}
.form-field {
display: grid;
gap: 0.28rem;