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__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/ Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__AllowInternalHttpTokenEndpoint=false 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__Path=
Auth__Certificates__Signing__Password= Auth__Certificates__Signing__Password=
Auth__Certificates__Encryption__Path= Auth__Certificates__Encryption__Path=
@ -19,6 +20,7 @@ Auth__Certificates__Encryption__Previous__0__Password=
DataProtection__Certificate__Previous__0__Path= DataProtection__Certificate__Previous__0__Path=
DataProtection__Certificate__Previous__0__Password= DataProtection__Certificate__Previous__0__Password=
Certificates__ExpirationWarningDays=30 Certificates__ExpirationWarningDays=30
ReverseProxy__TrustForwardedHeaders=false
ReverseProxy__KnownProxies= ReverseProxy__KnownProxies=
ReverseProxy__KnownNetworks= ReverseProxy__KnownNetworks=
ReverseProxy__ForwardLimit=1 ReverseProxy__ForwardLimit=1

View File

@ -81,21 +81,27 @@ OAuth usage/scope mapping 的正式來源為 DB registryaudience key 只作 s
| Key | 預設值 | 說明 | | Key | 預設值 | 說明 |
|---|---:|---| |---|---:|---|
| `Auth:Certificates:Signing:Path/Password` | 無 | Production API 必填 PFX。 | | DB `system_flags.certificates:openiddict:signing` | installer 產生 | Production API JWT signing PFX。 |
| `Auth:Certificates:Encryption:Path/Password` | 無 | Production API 必填 PFX。 | | DB `system_flags.certificates:openiddict:encryption` | installer 產生 | Production API OpenIddict encryption PFX。 |
| `DataProtection:Certificate:Path/Password` | 無 | Production API/Web 必填且共用 PFX。 | | DB `system_flags.certificates:data-protection` | installer 產生 | Production API/Web 共用 Data Protection PFX。 |
| `*:Previous:0:Path/Password` | 無 | 前代憑證,可增加索引。 | | `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。 | | `Certificates:ExpirationWarningDays` | `30` | 到期 warning有效範圍 1180。 |
## Reverse proxy ## Reverse proxy
| Key | 預設值 | 說明 | | Key | 預設值 | 說明 |
|---|---:|---| |---|---:|---|
| `ReverseProxy:TrustForwardedHeaders` | `false` | 設為 `true` 時信任 forwarded headers不要求 `KnownProxies` / `KnownNetworks`;僅可在 app inbound 已由 Security Group / 私有網路限制為可信 proxy 時使用。 |
| `ReverseProxy:KnownProxies` | 空 | 逗號分隔可信 proxy IP。 | | `ReverseProxy:KnownProxies` | 空 | 逗號分隔可信 proxy IP。 |
| `ReverseProxy:KnownNetworks` | 空 | 逗號分隔可信 CIDR禁止 `/0`。 | | `ReverseProxy:KnownNetworks` | 空 | 逗號分隔可信 CIDR禁止 `/0`。 |
| `ReverseProxy:ForwardLimit` | `1` | Proxy hop限制 15。 | | `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__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/ Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__AllowInternalHttpTokenEndpoint=false Auth__AllowInternalHttpTokenEndpoint=false
# Optional certificate file overrides. Normally installer provisions app certificates into DB.
Auth__Certificates__Signing__Path= Auth__Certificates__Signing__Path=
Auth__Certificates__Signing__Password= Auth__Certificates__Signing__Password=
Auth__Certificates__Encryption__Path= Auth__Certificates__Encryption__Path=
@ -87,19 +88,24 @@ OIDC / Redirect login 設定說明:
- 同 VPC 服務若需直接呼叫私有 HTTP `/oauth/token`,設定 `Auth__AllowInternalHttpTokenEndpoint=true`;此設定只放寬 OAuth endpoint transport不放寬 issuer 或外部 return URL。 - 同 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。 - 私有 HTTP listener 必須以 Security Group 限制來源CMS 可使用內部 HTTP token endpoint但驗證 token 時仍必須接受 canonical HTTPS issuer。
- 非 Development 若 `Auth__Issuer` 不是 HTTPSAPI 會拒絕啟動Login / Logout 的外部 HTTP return URL 也會被拒絕。 - 非 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 信任設定: 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__KnownProxies` 使用逗號分隔 IP例如 `10.0.0.10,10.0.0.11`
- `ReverseProxy__KnownNetworks` 使用逗號分隔 CIDR例如 `10.0.0.0/24,fd00::/64` - `ReverseProxy__KnownNetworks` 使用逗號分隔 CIDR例如 `10.0.0.0/24,fd00::/64`
- `ReverseProxy__ForwardLimit` 預設為 `1`,只應設為實際 proxy hop 數,允許範圍 `15` - `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 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` - 同時送出 `X-Content-Type-Options: nosniff``X-Frame-Options: DENY``Referrer-Policy: no-referrer` 與受限 `Permissions-Policy`
- 新增外部資產或第三方前端服務時,必須先明確調整 CSP不可直接加入 `'unsafe-inline'` 或萬用來源。 - 新增外部資產或第三方前端服務時,必須先明確調整 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 密碼儲存:
- `smtp_password``protected:v1:` 開頭的 Data Protection ciphertext 儲存,不需要新增 migration 或重建既有 DB。 - `smtp_password``protected:v1:` 開頭的 Data Protection ciphertext 儲存,不需要新增 migration 或重建既有 DB。
@ -107,12 +113,14 @@ SMTP 密碼儲存:
- `DataProtectionKeys` 是解密必要資料不可任意清空Production 應再以外部憑證保護 key ring並將憑證納入備份與輪替程序。 - `DataProtectionKeys` 是解密必要資料不可任意清空Production 應再以外部憑證保護 key ring並將憑證納入備份與輪替程序。
Production 憑證: Production 憑證:
- 非 Development 啟動 API 時必須提供 `Auth__Certificates__Signing__Path/Password``Auth__Certificates__Encryption__Path/Password`;不可再使用 development certificates。 - Installer `init` / `migrate` 會在 DB `system_flags` 自動產生三張 app-managed PFXOpenIddict signing、OpenIddict encryption、Data Protection。
- API 與 Web 都必須提供相同的 `DataProtection__Certificate__Path/Password`,用來保護共用 DB key ring。 - API 非 Development 啟動時會優先使用 `Auth__Certificates__Signing__Path` / `Auth__Certificates__Encryption__Path`,未設定時改讀 DB。
- 憑證檔必須是含 private key、在有效期內的 PFX密碼只可由 secret manager / environment 注入,不可提交到 repo。 - Web 非 Development 啟動時會優先使用 `DataProtection__Certificate__Path`,未設定時改讀 DB。
- signing、encryption 與 Data Protection 憑證建議分離;輪替時需保留仍用於驗證舊 token解密舊資料的前一代憑證完成相容輪替後才能移除。 - API 與 Web 必須使用同一份 DB 與同一份 Data Protection certificate因此正式部署順序是先跑 installer migration再啟動 API/Web。
- 前代憑證以 `__Previous__0__Path/Password` 設定,可依序增加 `Previous__1__...`;目前憑證用於新簽發/新 key前代憑證保留舊 token 驗證與 key 解密能力。 - 檔案型 PFX 設定只作為覆寫或手動輪替用途;若設定了 path檔案必須存在、含 private key 且在有效期內。
- 應監控憑證到期日並先在 Stage 驗證;路徑錯誤、缺 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 邊界負責。 - 此處監控的是 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。 - `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"; connectionString = "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
} }
var requireProductionCertificates = !builder.Environment.IsDevelopment(); var requireProductionCertificates = !builder.Environment.IsDevelopment();
var signingCertificate = CertificateLoader.LoadFromConfiguration( var signingCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync(
builder.Configuration, "Auth:Certificates:Signing", requireProductionCertificates); builder.Configuration,
connectionString,
"Auth:Certificates:Signing",
CertificateLoader.OpenIddictSigningKey,
requireProductionCertificates);
var previousSigningCertificates = CertificateLoader.LoadPreviousFromConfiguration( var previousSigningCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "Auth:Certificates:Signing"); builder.Configuration, "Auth:Certificates:Signing");
var encryptionCertificate = CertificateLoader.LoadFromConfiguration( var encryptionCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync(
builder.Configuration, "Auth:Certificates:Encryption", requireProductionCertificates); builder.Configuration,
connectionString,
"Auth:Certificates:Encryption",
CertificateLoader.OpenIddictEncryptionKey,
requireProductionCertificates);
var previousEncryptionCertificates = CertificateLoader.LoadPreviousFromConfiguration( var previousEncryptionCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "Auth:Certificates:Encryption"); builder.Configuration, "Auth:Certificates:Encryption");
var dataProtectionCertificate = CertificateLoader.LoadFromConfiguration( var dataProtectionCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync(
builder.Configuration, "DataProtection:Certificate", requireProductionCertificates); builder.Configuration,
connectionString,
"DataProtection:Certificate",
CertificateLoader.DataProtectionKey,
requireProductionCertificates);
var previousDataProtectionCertificates = CertificateLoader.LoadPreviousFromConfiguration( var previousDataProtectionCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "DataProtection:Certificate"); builder.Configuration, "DataProtection:Certificate");
if (!builder.Environment.IsDevelopment() && issuerUri is null) if (!builder.Environment.IsDevelopment() && issuerUri is null)
@ -263,6 +275,7 @@ builder.Services.AddScoped<ITenantManagerService, TenantManagerService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>(); builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>(); builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>(); builder.Services.AddScoped<IProfileService, ProfileService>();
builder.Services.AddScoped<IUserConsentService, UserConsentService>();
builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>(); builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistryService>();
builder.Services.AddScoped<AdminPermissionService>(); builder.Services.AddScoped<AdminPermissionService>();
builder.Services.AddScoped<IAdminPermissionChecker>(services => services.GetRequiredService<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 System.Security.Cryptography.X509Certificates;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Configuration; namespace MemberCenter.Infrastructure.Configuration;
public static class CertificateLoader 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( public static void LogExpirationWarning(
ILogger logger, ILogger logger,
string name, string name,
@ -72,19 +79,161 @@ public static class CertificateLoader
throw new InvalidOperationException($"Certificate configured by {sectionPath} could not be loaded.", ex); 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; var now = DateTime.UtcNow;
if (!certificate.HasPrivateKey) if (!certificate.HasPrivateKey)
{ {
certificate.Dispose(); 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()) if (now < certificate.NotBefore.ToUniversalTime() || now >= certificate.NotAfter.ToUniversalTime())
{ {
certificate.Dispose(); 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.KnownNetworks.Clear();
options.KnownProxies.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"])) foreach (var value in Split(configuration["ReverseProxy:KnownProxies"]))
{ {
if (!IPAddress.TryParse(value, out var address)) 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; var hasTrustedProxy = options.KnownProxies.Count > 0 || options.KnownNetworks.Count > 0;
options.ForwardedHeaders = hasTrustedProxy options.ForwardedHeaders = hasTrustedProxy
? ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto ? ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
: ForwardedHeaders.None; : ForwardedHeaders.None;
options.ForwardLimit = Math.Clamp(configuration.GetValue<int?>("ReverseProxy:ForwardLimit") ?? 1, 1, 5); 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<NewsletterSubscription> NewsletterSubscriptions => Set<NewsletterSubscription>();
public DbSet<UserProfile> UserProfiles => Set<UserProfile>(); public DbSet<UserProfile> UserProfiles => Set<UserProfile>();
public DbSet<UserProfileImage> UserProfileImages => Set<UserProfileImage>(); public DbSet<UserProfileImage> UserProfileImages => Set<UserProfileImage>();
public DbSet<UserConsent> UserConsents => Set<UserConsent>();
public DbSet<UserAddress> UserAddresses => Set<UserAddress>(); public DbSet<UserAddress> UserAddresses => Set<UserAddress>();
public DbSet<EmailBlacklist> EmailBlacklist => Set<EmailBlacklist>(); public DbSet<EmailBlacklist> EmailBlacklist => Set<EmailBlacklist>();
public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>(); public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>();
@ -145,6 +146,23 @@ public class MemberCenterDbContext
.OnDelete(DeleteBehavior.Cascade); .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 => builder.Entity<UserAddress>(entity =>
{ {
entity.ToTable("user_addresses"); 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); 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 => modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1364,6 +1409,15 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
.IsRequired(); .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 => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{ {
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) 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 db.Database.MigrateAsync();
await CertificateLoader.EnsureDatabaseCertificatesAsync(db);
await db.SaveChangesAsync();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>(); var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync(); await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>(); var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
@ -274,6 +276,8 @@ migrateCommand.SetHandler(async (string? connectionString, string? appsettings,
if (string.IsNullOrWhiteSpace(target)) if (string.IsNullOrWhiteSpace(target))
{ {
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
await CertificateLoader.EnsureDatabaseCertificatesAsync(db);
await db.SaveChangesAsync();
var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>(); var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync(); await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>(); var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();
@ -302,6 +306,9 @@ syncOAuthClientsCommand.SetHandler(async (string? connectionString, string? apps
var services = BuildServices(resolvedConnection); var services = BuildServices(resolvedConnection);
await using var scope = services.CreateAsyncScope(); 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>(); var registry = scope.ServiceProvider.GetRequiredService<IAuthResourceRegistryService>();
await registry.EnsureDefaultsAsync(); await registry.EnsureDefaultsAsync();
var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>(); var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService<IAdminPermissionSeeder>();

View File

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

View File

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

View File

@ -4,11 +4,13 @@ namespace MemberCenter.Web.Models.Account;
public sealed class LoginViewModel public sealed class LoginViewModel
{ {
[Required] [Display(Name = "Email")]
[EmailAddress] [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; public string Email { get; set; } = string.Empty;
[Required] [Display(Name = "Password")]
[Required(ErrorMessage = "The {0} field is required.")]
[DataType(DataType.Password)] [DataType(DataType.Password)]
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;

View File

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

View File

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

View File

@ -4,10 +4,12 @@ namespace MemberCenter.Web.Models.Admin;
public sealed class EmailBlacklistFormViewModel public sealed class EmailBlacklistFormViewModel
{ {
[Required] [Display(Name = "Email")]
[EmailAddress] [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; 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; public string Reason { get; set; } = string.Empty;
} }

View File

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

View File

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

View File

@ -6,12 +6,14 @@ public sealed class TenantFormViewModel
{ {
public Guid? Id { get; set; } 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 Name { get; set; } = string.Empty;
public string Domains { 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 Status { get; set; } = "active";
public string? SendEngineWebhookClientId { get; set; } public string? SendEngineWebhookClientId { get; set; }

View File

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

View File

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

View File

@ -6,42 +6,53 @@ public sealed class AddressFormViewModel
{ {
public Guid? Id { get; set; } public Guid? Id { get; set; }
[Required] [Display(Name = "Label")]
[StringLength(100)] [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"; public string Label { get; set; } = "home";
[Required] [Display(Name = "Recipient name")]
[StringLength(100)] [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; public string RecipientName { get; set; } = string.Empty;
[Required] [Display(Name = "Recipient phone")]
[StringLength(50)] [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; public string RecipientPhone { get; set; } = string.Empty;
[Required] [Display(Name = "Country code")]
[StringLength(2, MinimumLength = 2)] [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"; 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; } 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; } 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; } 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; } public string? District { get; set; }
[Required] [Display(Name = "Address line 1")]
[StringLength(255)] [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; 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; } 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 string? CompanyName { get; set; }
public bool IsDefault { get; set; } public bool IsDefault { get; set; }

View File

@ -4,48 +4,62 @@ namespace MemberCenter.Web.Models.Profile;
public sealed class ProfileViewModel public sealed class ProfileViewModel
{ {
[Required] [Display(Name = "Last name")]
[StringLength(100)] [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; public string LastName { get; set; } = string.Empty;
[Required] [Display(Name = "First name")]
[StringLength(100)] [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; 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; } 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; } 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; } public string? LandlinePhone { get; set; }
[Display(Name = "Date of birth")]
[DataType(DataType.Date)] [DataType(DataType.Date)]
public DateOnly? DateOfBirth { get; set; } public DateOnly? DateOfBirth { get; set; }
[Required] [Display(Name = "Gender")]
[Required(ErrorMessage = "The {0} field is required.")]
public string Gender { get; set; } = "unspecified"; 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; } 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; } 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; } 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; } 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; } 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; } 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? Remark { get; set; }
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;

View File

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

View File

@ -35,6 +35,7 @@
<data name="Create" xml:space="preserve"><value>建立</value></data> <data name="Create" xml:space="preserve"><value>建立</value></data>
<data name="Create account" 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="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 count" xml:space="preserve"><value>現人數</value></data>
<data name="Current total" 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> <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 email" xml:space="preserve"><value>輸入你的 Email</value></data>
<data name="Enter your password" xml:space="preserve"><value>輸入你的密碼</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="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 Newsletter" xml:space="preserve"><value>編輯電子報</value></data>
<data name="Edit OAuth Client" xml:space="preserve"><value>編輯 OAuth Client</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> <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="Freeze account" xml:space="preserve"><value>帳號凍結</value></data>
<data name="Frozen" 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="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="Home" xml:space="preserve"><value>主頁</value></data>
<data name="I have read and agree to the" 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> <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="Remember me" xml:space="preserve"><value>記住我</value></data>
<data name="Register" 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="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="Resend verification email" xml:space="preserve"><value>重新寄送驗證信</value></data>
<data name="Save" xml:space="preserve"><value>儲存</value></data> <data name="Save" xml:space="preserve"><value>儲存</value></data>
<data name="Search" 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="unspecified" xml:space="preserve"><value>未指定</value></data>
<data name="Webhook Client ID" xml:space="preserve"><value>Webhook Client ID</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="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> </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> <h1>@L["Password Reset"]</h1>
<p>@L["If the email exists, a password reset email has been sent."]</p> <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; 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 { .form-field {
display: grid; display: grid;
gap: 0.28rem; gap: 0.28rem;