Load certificates from DB and trust forwarded headers
This commit is contained in:
parent
5b77669ab4
commit
96a1584bbe
@ -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
|
||||||
|
|||||||
@ -81,21 +81,27 @@ OAuth usage/scope mapping 的正式來源為 DB registry;audience 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;有效範圍 1–180。 |
|
| `Certificates:ExpirationWarningDays` | `30` | 到期 warning;有效範圍 1–180。 |
|
||||||
|
|
||||||
## 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;限制 1–5。 |
|
| `ReverseProxy:ForwardLimit` | `1` | Proxy hop;限制 1–5。 |
|
||||||
|
|
||||||
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 headers;allowlist 都為空時完全忽略 forwarded headers。
|
||||||
|
|
||||||
## 外部整合與測試旗標
|
## 外部整合與測試旗標
|
||||||
|
|
||||||
|
|||||||
@ -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` 不是 HTTPS,API 會拒絕啟動;Login / Logout 的外部 HTTP return URL 也會被拒絕。
|
- 非 Development 若 `Auth__Issuer` 不是 HTTPS,API 會拒絕啟動;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 數,允許範圍 `1–5`。
|
- `ReverseProxy__ForwardLimit` 預設為 `1`,只應設為實際 proxy hop 數,允許範圍 `1–5`。
|
||||||
- 不可填入 `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 PFX:OpenIddict 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`(允許 `1–180`);進入期限後 API / Web 啟動會寫入 warning log,Production 應以 CloudWatch metric filter / alarm 監控該 warning。
|
- `Certificates__ExpirationWarningDays` 預設 `30`(允許 `1–180`);進入期限後 API / Web 啟動會寫入 warning log,Production 應以 CloudWatch metric filter / alarm 監控該 warning。
|
||||||
|
|
||||||
|
|||||||
@ -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>());
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IUserConsentService
|
||||||
|
{
|
||||||
|
Task RecordRegistrationConsentAsync(
|
||||||
|
Guid userId,
|
||||||
|
string registrationMethod,
|
||||||
|
string? ipAddress,
|
||||||
|
string? userAgent,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
}
|
||||||
13
src/MemberCenter.Domain/Entities/UserConsent.cs
Normal file
13
src/MemberCenter.Domain/Entities/UserConsent.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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]
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user