diff --git a/.env.example b/.env.example index 1156732..6105eba 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ Auth__WebLoginUrl=http://localhost:5080/account/login Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/ Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/ Auth__AllowInternalHttpTokenEndpoint=false +# Optional PFX file overrides. Production normally reads app-managed certificates provisioned by installer into DB. Auth__Certificates__Signing__Path= Auth__Certificates__Signing__Password= Auth__Certificates__Encryption__Path= @@ -19,6 +20,7 @@ Auth__Certificates__Encryption__Previous__0__Password= DataProtection__Certificate__Previous__0__Path= DataProtection__Certificate__Previous__0__Password= Certificates__ExpirationWarningDays=30 +ReverseProxy__TrustForwardedHeaders=false ReverseProxy__KnownProxies= ReverseProxy__KnownNetworks= ReverseProxy__ForwardLimit=1 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index dfd92f7..fb66a66 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -81,21 +81,27 @@ OAuth usage/scope mapping 的正式來源為 DB registry;audience key 只作 s | Key | 預設值 | 說明 | |---|---:|---| -| `Auth:Certificates:Signing:Path/Password` | 無 | Production API 必填 PFX。 | -| `Auth:Certificates:Encryption:Path/Password` | 無 | Production API 必填 PFX。 | -| `DataProtection:Certificate:Path/Password` | 無 | Production API/Web 必填且共用 PFX。 | -| `*:Previous:0:Path/Password` | 無 | 前代憑證,可增加索引。 | +| DB `system_flags.certificates:openiddict:signing` | installer 產生 | Production API JWT signing PFX。 | +| DB `system_flags.certificates:openiddict:encryption` | installer 產生 | Production API OpenIddict encryption PFX。 | +| DB `system_flags.certificates:data-protection` | installer 產生 | Production API/Web 共用 Data Protection PFX。 | +| `Auth:Certificates:Signing:Path/Password` | 無 | 選填;覆寫 DB signing PFX。 | +| `Auth:Certificates:Encryption:Path/Password` | 無 | 選填;覆寫 DB encryption PFX。 | +| `DataProtection:Certificate:Path/Password` | 無 | 選填;覆寫 DB Data Protection PFX。 | +| `*:Previous:0:Path/Password` | 無 | 選填;檔案型前代憑證,可增加索引。 | | `Certificates:ExpirationWarningDays` | `30` | 到期 warning;有效範圍 1–180。 | ## Reverse proxy | Key | 預設值 | 說明 | |---|---:|---| +| `ReverseProxy:TrustForwardedHeaders` | `false` | 設為 `true` 時信任 forwarded headers,不要求 `KnownProxies` / `KnownNetworks`;僅可在 app inbound 已由 Security Group / 私有網路限制為可信 proxy 時使用。 | | `ReverseProxy:KnownProxies` | 空 | 逗號分隔可信 proxy IP。 | | `ReverseProxy:KnownNetworks` | 空 | 逗號分隔可信 CIDR;禁止 `/0`。 | | `ReverseProxy:ForwardLimit` | `1` | Proxy hop;限制 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。 ## 外部整合與測試旗標 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 787141d..7ff1df4 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -49,6 +49,7 @@ Auth__WebLoginUrl=http://localhost:5080/account/login Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/ Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/ Auth__AllowInternalHttpTokenEndpoint=false +# Optional certificate file overrides. Normally installer provisions app certificates into DB. Auth__Certificates__Signing__Path= Auth__Certificates__Signing__Password= Auth__Certificates__Encryption__Path= @@ -87,19 +88,24 @@ OIDC / Redirect login 設定說明: - 同 VPC 服務若需直接呼叫私有 HTTP `/oauth/token`,設定 `Auth__AllowInternalHttpTokenEndpoint=true`;此設定只放寬 OAuth endpoint transport,不放寬 issuer 或外部 return URL。 - 私有 HTTP listener 必須以 Security Group 限制來源;CMS 可使用內部 HTTP token endpoint,但驗證 token 時仍必須接受 canonical HTTPS issuer。 - 非 Development 若 `Auth__Issuer` 不是 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 信任設定: -- 未設定 `ReverseProxy__KnownProxies` / `ReverseProxy__KnownNetworks` 時,API 與 Web 完全忽略 `X-Forwarded-For`、`X-Forwarded-Proto`。 +- AWS ALB / managed reverse proxy 的 private IP 會變動時,建議由 Security Group 限制 Member Center 只接受該 proxy 連入,並設定: + - `ReverseProxy__TrustForwardedHeaders=true` + - `ReverseProxy__ForwardLimit=1` +- `ReverseProxy__TrustForwardedHeaders=true` 會接受 `X-Forwarded-For`、`X-Forwarded-Proto`、`X-Forwarded-Host`;只有在 app inbound 已由 Security Group / 私有網路限制為可信 proxy 時才可使用。 +- 若不使用 `TrustForwardedHeaders`,未設定 `ReverseProxy__KnownProxies` / `ReverseProxy__KnownNetworks` 時,API 與 Web 完全忽略 forwarded headers。 - `ReverseProxy__KnownProxies` 使用逗號分隔 IP,例如 `10.0.0.10,10.0.0.11`。 - `ReverseProxy__KnownNetworks` 使用逗號分隔 CIDR,例如 `10.0.0.0/24,fd00::/64`。 - `ReverseProxy__ForwardLimit` 預設為 `1`,只應設為實際 proxy hop 數,允許範圍 `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 全域送出 self-only Content Security Policy,禁止 object、外部 frame 與 inline script/style。 +- Web 全域送出受限 Content Security Policy,禁止 object、外部 frame 與 inline script/style。 - 同時送出 `X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy: no-referrer` 與受限 `Permissions-Policy`。 - 新增外部資產或第三方前端服務時,必須先明確調整 CSP,不可直接加入 `'unsafe-inline'` 或萬用來源。 +- Google login 啟用時,`form-action` 會額外允許 `https://accounts.google.com`,因為 `/Account/ExternalLogin` 的 form submit 會被 ASP.NET Core external auth challenge 導向 Google OAuth endpoint。 SMTP 密碼儲存: - `smtp_password` 以 `protected:v1:` 開頭的 Data Protection ciphertext 儲存,不需要新增 migration 或重建既有 DB。 @@ -107,12 +113,14 @@ SMTP 密碼儲存: - `DataProtectionKeys` 是解密必要資料,不可任意清空;Production 應再以外部憑證保護 key ring,並將憑證納入備份與輪替程序。 Production 憑證: -- 非 Development 啟動 API 時必須提供 `Auth__Certificates__Signing__Path/Password` 與 `Auth__Certificates__Encryption__Path/Password`;不可再使用 development certificates。 -- API 與 Web 都必須提供相同的 `DataProtection__Certificate__Path/Password`,用來保護共用 DB key ring。 -- 憑證檔必須是含 private key、在有效期內的 PFX;密碼只可由 secret manager / environment 注入,不可提交到 repo。 -- signing、encryption 與 Data Protection 憑證建議分離;輪替時需保留仍用於驗證舊 token/解密舊資料的前一代憑證,完成相容輪替後才能移除。 -- 前代憑證以 `__Previous__0__Path/Password` 設定,可依序增加 `Previous__1__...`;目前憑證用於新簽發/新 key,前代憑證保留舊 token 驗證與 key 解密能力。 -- 應監控憑證到期日並先在 Stage 驗證;路徑錯誤、缺 private key或已過期時應用程式會拒絕啟動。 +- Installer `init` / `migrate` 會在 DB `system_flags` 自動產生三張 app-managed PFX:OpenIddict signing、OpenIddict encryption、Data Protection。 +- API 非 Development 啟動時會優先使用 `Auth__Certificates__Signing__Path` / `Auth__Certificates__Encryption__Path`,未設定時改讀 DB。 +- Web 非 Development 啟動時會優先使用 `DataProtection__Certificate__Path`,未設定時改讀 DB。 +- API 與 Web 必須使用同一份 DB 與同一份 Data Protection certificate;因此正式部署順序是先跑 installer migration,再啟動 API/Web。 +- 檔案型 PFX 設定只作為覆寫或手動輪替用途;若設定了 path,檔案必須存在、含 private key 且在有效期內。 +- signing、encryption 與 Data Protection 憑證分離;輪替時需保留仍用於驗證舊 token/解密舊資料的前一代憑證,完成相容輪替後才能移除。 +- 前代檔案憑證以 `__Previous__0__Path/Password` 設定,可依序增加 `Previous__1__...`;DB-managed 前代輪替管理介面尚未建立。 +- 應監控憑證到期日並先在 Stage 驗證;DB certificate 缺失、缺 private key 或已過期時應用程式會拒絕啟動。 - 此處監控的是 OpenIddict signing / encryption 與 Data Protection PFX;對外 TLS/SSL 憑證若由 AWS ACM 管理,續期與告警由 ACM / AWS 邊界負責。 - `Certificates__ExpirationWarningDays` 預設 `30`(允許 `1–180`);進入期限後 API / Web 啟動會寫入 warning log,Production 應以 CloudWatch metric filter / alarm 監控該 warning。 diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 631fee9..f4bb819 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -42,16 +42,28 @@ if (string.IsNullOrWhiteSpace(connectionString)) connectionString = "Host=localhost;Database=member_center;Username=postgres;Password=postgres"; } var requireProductionCertificates = !builder.Environment.IsDevelopment(); -var signingCertificate = CertificateLoader.LoadFromConfiguration( - builder.Configuration, "Auth:Certificates:Signing", requireProductionCertificates); +var signingCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync( + builder.Configuration, + connectionString, + "Auth:Certificates:Signing", + CertificateLoader.OpenIddictSigningKey, + requireProductionCertificates); var previousSigningCertificates = CertificateLoader.LoadPreviousFromConfiguration( builder.Configuration, "Auth:Certificates:Signing"); -var encryptionCertificate = CertificateLoader.LoadFromConfiguration( - builder.Configuration, "Auth:Certificates:Encryption", requireProductionCertificates); +var encryptionCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync( + builder.Configuration, + connectionString, + "Auth:Certificates:Encryption", + CertificateLoader.OpenIddictEncryptionKey, + requireProductionCertificates); var previousEncryptionCertificates = CertificateLoader.LoadPreviousFromConfiguration( builder.Configuration, "Auth:Certificates:Encryption"); -var dataProtectionCertificate = CertificateLoader.LoadFromConfiguration( - builder.Configuration, "DataProtection:Certificate", requireProductionCertificates); +var dataProtectionCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync( + builder.Configuration, + connectionString, + "DataProtection:Certificate", + CertificateLoader.DataProtectionKey, + requireProductionCertificates); var previousDataProtectionCertificates = CertificateLoader.LoadPreviousFromConfiguration( builder.Configuration, "DataProtection:Certificate"); if (!builder.Environment.IsDevelopment() && issuerUri is null) @@ -263,6 +275,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(services => services.GetRequiredService()); diff --git a/src/MemberCenter.Application/Abstractions/IUserConsentService.cs b/src/MemberCenter.Application/Abstractions/IUserConsentService.cs new file mode 100644 index 0000000..cfcce17 --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IUserConsentService.cs @@ -0,0 +1,11 @@ +namespace MemberCenter.Application.Abstractions; + +public interface IUserConsentService +{ + Task RecordRegistrationConsentAsync( + Guid userId, + string registrationMethod, + string? ipAddress, + string? userAgent, + CancellationToken cancellationToken = default); +} diff --git a/src/MemberCenter.Application/Constants/ConsentVersions.cs b/src/MemberCenter.Application/Constants/ConsentVersions.cs new file mode 100644 index 0000000..063bdf1 --- /dev/null +++ b/src/MemberCenter.Application/Constants/ConsentVersions.cs @@ -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"; +} diff --git a/src/MemberCenter.Domain/Entities/UserConsent.cs b/src/MemberCenter.Domain/Entities/UserConsent.cs new file mode 100644 index 0000000..14e5f46 --- /dev/null +++ b/src/MemberCenter.Domain/Entities/UserConsent.cs @@ -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; +} diff --git a/src/MemberCenter.Infrastructure/Configuration/CertificateLoader.cs b/src/MemberCenter.Infrastructure/Configuration/CertificateLoader.cs index bb1053d..823b039 100644 --- a/src/MemberCenter.Infrastructure/Configuration/CertificateLoader.cs +++ b/src/MemberCenter.Infrastructure/Configuration/CertificateLoader.cs @@ -1,11 +1,18 @@ using System.Security.Cryptography.X509Certificates; +using MemberCenter.Domain.Entities; +using MemberCenter.Infrastructure.Persistence; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; namespace MemberCenter.Infrastructure.Configuration; public static class CertificateLoader { + public const string OpenIddictSigningKey = "certificates:openiddict:signing"; + public const string OpenIddictEncryptionKey = "certificates:openiddict:encryption"; + public const string DataProtectionKey = "certificates:data-protection"; + public static void LogExpirationWarning( ILogger logger, string name, @@ -72,19 +79,161 @@ public static class CertificateLoader throw new InvalidOperationException($"Certificate configured by {sectionPath} could not be loaded.", ex); } + ValidateCertificate(certificate, $"configured by {sectionPath}"); + + return certificate; + } + + public static async Task 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 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() + .UseNpgsql(connectionString) + .UseOpenIddict() + .Options; + + return new MemberCenterDbContext(options); + } + + private static void ValidateCertificate(X509Certificate2 certificate, string source) + { var now = DateTime.UtcNow; if (!certificate.HasPrivateKey) { certificate.Dispose(); - throw new InvalidOperationException($"Certificate configured by {sectionPath} must contain a private key."); + throw new InvalidOperationException($"Certificate {source} must contain a private key."); } if (now < certificate.NotBefore.ToUniversalTime() || now >= certificate.NotAfter.ToUniversalTime()) { certificate.Dispose(); - throw new InvalidOperationException($"Certificate configured by {sectionPath} is not currently valid."); + throw new InvalidOperationException($"Certificate {source} is not currently valid."); } - - return certificate; } } diff --git a/src/MemberCenter.Infrastructure/Configuration/TrustedForwardedHeaders.cs b/src/MemberCenter.Infrastructure/Configuration/TrustedForwardedHeaders.cs index 237b334..99e6e8d 100644 --- a/src/MemberCenter.Infrastructure/Configuration/TrustedForwardedHeaders.cs +++ b/src/MemberCenter.Infrastructure/Configuration/TrustedForwardedHeaders.cs @@ -12,6 +12,16 @@ public static class TrustedForwardedHeaders options.KnownNetworks.Clear(); options.KnownProxies.Clear(); + var trustForwardedHeaders = configuration.GetValue("ReverseProxy:TrustForwardedHeaders", false); + if (trustForwardedHeaders) + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedProto | + ForwardedHeaders.XForwardedHost; + options.ForwardLimit = Math.Clamp(configuration.GetValue("ReverseProxy:ForwardLimit") ?? 1, 1, 5); + return; + } + foreach (var value in Split(configuration["ReverseProxy:KnownProxies"])) { if (!IPAddress.TryParse(value, out var address)) @@ -39,7 +49,7 @@ public static class TrustedForwardedHeaders var hasTrustedProxy = options.KnownProxies.Count > 0 || options.KnownNetworks.Count > 0; options.ForwardedHeaders = hasTrustedProxy - ? ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto + ? ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost : ForwardedHeaders.None; options.ForwardLimit = Math.Clamp(configuration.GetValue("ReverseProxy:ForwardLimit") ?? 1, 1, 5); } diff --git a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs index fc54c56..384e9ca 100644 --- a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs +++ b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs @@ -20,6 +20,7 @@ public class MemberCenterDbContext public DbSet NewsletterSubscriptions => Set(); public DbSet UserProfiles => Set(); public DbSet UserProfileImages => Set(); + public DbSet UserConsents => Set(); public DbSet UserAddresses => Set(); public DbSet EmailBlacklist => Set(); public DbSet EmailVerifications => Set(); @@ -145,6 +146,23 @@ public class MemberCenterDbContext .OnDelete(DeleteBehavior.Cascade); }); + builder.Entity(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() + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + builder.Entity(entity => { entity.ToTable("user_addresses"); diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260713090000_AddUserConsents.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260713090000_AddUserConsents.cs new file mode 100644 index 0000000..5b7cc9c --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260713090000_AddUserConsents.cs @@ -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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TermsVersion = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + PrivacyVersion = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + RegistrationMethod = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + IpAddress = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + UserAgent = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + AcceptedAt = table.Column(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"); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs index 5a757f8..686125e 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs @@ -770,6 +770,51 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.ToTable("user_profile_images", (string)null); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.UserConsent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PrivacyVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RegistrationMethod") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TermsVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .HasDatabaseName("idx_user_consents_user_id"); + + b.ToTable("user_consents", (string)null); + }); + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b => { b.Property("Id") @@ -1364,6 +1409,15 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations .IsRequired(); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.UserConsent", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) diff --git a/src/MemberCenter.Infrastructure/Services/UserConsentService.cs b/src/MemberCenter.Infrastructure/Services/UserConsentService.cs new file mode 100644 index 0000000..b027c25 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/UserConsentService.cs @@ -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]; + } +} diff --git a/src/MemberCenter.Installer/Program.cs b/src/MemberCenter.Installer/Program.cs index a90ddbe..3c1420b 100644 --- a/src/MemberCenter.Installer/Program.cs +++ b/src/MemberCenter.Installer/Program.cs @@ -102,6 +102,8 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo } await db.Database.MigrateAsync(); + await CertificateLoader.EnsureDatabaseCertificatesAsync(db); + await db.SaveChangesAsync(); var registry = scope.ServiceProvider.GetRequiredService(); await registry.EnsureDefaultsAsync(); var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService(); @@ -274,6 +276,8 @@ migrateCommand.SetHandler(async (string? connectionString, string? appsettings, if (string.IsNullOrWhiteSpace(target)) { await db.Database.MigrateAsync(); + await CertificateLoader.EnsureDatabaseCertificatesAsync(db); + await db.SaveChangesAsync(); var registry = scope.ServiceProvider.GetRequiredService(); await registry.EnsureDefaultsAsync(); var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService(); @@ -302,6 +306,9 @@ syncOAuthClientsCommand.SetHandler(async (string? connectionString, string? apps var services = BuildServices(resolvedConnection); await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await CertificateLoader.EnsureDatabaseCertificatesAsync(db); + await db.SaveChangesAsync(); var registry = scope.ServiceProvider.GetRequiredService(); await registry.EnsureDefaultsAsync(); var adminPermissionSeeder = scope.ServiceProvider.GetRequiredService(); diff --git a/src/MemberCenter.Web/Controllers/AccountController.cs b/src/MemberCenter.Web/Controllers/AccountController.cs index 158bdd8..f1d7bb6 100644 --- a/src/MemberCenter.Web/Controllers/AccountController.cs +++ b/src/MemberCenter.Web/Controllers/AccountController.cs @@ -3,12 +3,14 @@ using MemberCenter.Application.Abstractions; using MemberCenter.Application.Constants; using MemberCenter.Infrastructure.Configuration; using MemberCenter.Infrastructure.Identity; +using MemberCenter.Web.Localization; using MemberCenter.Web.Models.Account; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Localization; namespace MemberCenter.Web.Controllers; @@ -16,31 +18,37 @@ public class AccountController : Controller { private readonly IAccountProvisioningService _accountProvisioningService; private readonly IAccountEmailService _accountEmailService; + private readonly IUserConsentService _userConsentService; private readonly IAuditLogWriter _auditLogWriter; private readonly IConfiguration _configuration; private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; private readonly bool _allowInsecureReturnUrls; private readonly UserManager _userManager; private readonly SignInManager _signInManager; + private readonly IStringLocalizer _localizer; public AccountController( IAccountProvisioningService accountProvisioningService, IAccountEmailService accountEmailService, + IUserConsentService userConsentService, IAuditLogWriter auditLogWriter, IConfiguration configuration, IAuthenticationSchemeProvider authenticationSchemeProvider, IWebHostEnvironment environment, UserManager userManager, - SignInManager signInManager) + SignInManager signInManager, + IStringLocalizer localizer) { _accountProvisioningService = accountProvisioningService; _accountEmailService = accountEmailService; + _userConsentService = userConsentService; _auditLogWriter = auditLogWriter; _configuration = configuration; _authenticationSchemeProvider = authenticationSchemeProvider; _allowInsecureReturnUrls = environment.IsDevelopment(); _userManager = userManager; _signInManager = signInManager; + _localizer = localizer; } [HttpGet] @@ -65,7 +73,7 @@ public class AccountController : Controller var loginUser = await _userManager.FindByEmailAsync(model.Email); if (loginUser?.DisabledAt.HasValue == true) { - ModelState.AddModelError(string.Empty, "Account is disabled."); + AddLocalizedModelError("Account is disabled."); await SetExternalLoginAvailabilityAsync(); return View(model); } @@ -75,12 +83,12 @@ public class AccountController : Controller { if (result.IsLockedOut) { - ModelState.AddModelError(string.Empty, "Account is temporarily locked. Please try again later."); + AddLocalizedModelError("Account is temporarily locked. Please try again later."); await SetExternalLoginAvailabilityAsync(); return View(model); } - ModelState.AddModelError(string.Empty, "Invalid login attempt."); + AddLocalizedModelError("Invalid login attempt."); await SetExternalLoginAvailabilityAsync(); return View(model); } @@ -105,7 +113,7 @@ public class AccountController : Controller { if (await _authenticationSchemeProvider.GetSchemeAsync(provider) is null) { - ModelState.AddModelError(string.Empty, $"{provider} login is not configured."); + AddLocalizedModelError("{0} login is not configured.", provider); await SetExternalLoginAvailabilityAsync(); return View("Login", new LoginViewModel { ReturnUrl = returnUrl, RememberMe = rememberMe }); } @@ -124,7 +132,7 @@ public class AccountController : Controller { if (!string.IsNullOrWhiteSpace(remoteError)) { - ModelState.AddModelError(string.Empty, $"External login failed: {remoteError}"); + AddLocalizedModelError("External login failed: {0}", remoteError); await SetExternalLoginAvailabilityAsync(); return View("Login", new LoginViewModel { ReturnUrl = returnUrl }); } @@ -132,13 +140,25 @@ public class AccountController : Controller var info = await _signInManager.GetExternalLoginInfoAsync(); if (info is null) { - ModelState.AddModelError(string.Empty, "Unable to load external login information."); + AddLocalizedModelError("Unable to load external login information."); await SetExternalLoginAvailabilityAsync(); return View("Login", new LoginViewModel { ReturnUrl = returnUrl }); } var email = info.Principal.FindFirstValue(ClaimTypes.Email) ?? info.Principal.FindFirstValue("email"); var emailVerified = bool.TryParse(info.Principal.FindFirstValue("email_verified"), out var parsed) && parsed; + var existingExternalUser = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey); + if (existingExternalUser is null) + { + return View("ExternalRegisterConfirmation", new ExternalRegisterConfirmationViewModel + { + Provider = info.LoginProvider, + Email = email, + ReturnUrl = returnUrl, + RememberMe = rememberMe + }); + } + var result = await _accountProvisioningService.ProvisionExternalLoginAsync( info.LoginProvider, info.ProviderKey, @@ -149,7 +169,7 @@ public class AccountController : Controller { foreach (var error in result.Errors) { - ModelState.AddModelError(string.Empty, error); + AddLocalizedModelError(error); } await SetExternalLoginAvailabilityAsync(); @@ -159,14 +179,14 @@ public class AccountController : Controller var user = await _userManager.FindByIdAsync(result.UserId.Value.ToString()); if (user is null) { - ModelState.AddModelError(string.Empty, "Unable to locate the linked account."); + AddLocalizedModelError("Unable to locate the linked account."); await SetExternalLoginAvailabilityAsync(); return View("Login", new LoginViewModel { ReturnUrl = returnUrl }); } if (user.DisabledAt.HasValue) { - ModelState.AddModelError(string.Empty, "Account is disabled."); + AddLocalizedModelError("Account is disabled."); await SetExternalLoginAvailabilityAsync(); return View("Login", new LoginViewModel { ReturnUrl = returnUrl }); } @@ -182,6 +202,76 @@ public class AccountController : Controller return RedirectToAction("Index", "Home", new { area = string.Empty }); } + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + [EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)] + public async Task ExternalRegisterConfirmation(ExternalRegisterConfirmationViewModel model) + { + var info = await _signInManager.GetExternalLoginInfoAsync(); + if (info is null) + { + AddLocalizedModelError("Unable to load external login information."); + await SetExternalLoginAvailabilityAsync(); + return View("Login", new LoginViewModel { ReturnUrl = model.ReturnUrl, RememberMe = model.RememberMe }); + } + + model.Provider = info.LoginProvider; + model.Email = info.Principal.FindFirstValue(ClaimTypes.Email) ?? info.Principal.FindFirstValue("email"); + if (!ModelState.IsValid) + { + return View(model); + } + + var emailVerified = bool.TryParse(info.Principal.FindFirstValue("email_verified"), out var parsed) && parsed; + var result = await _accountProvisioningService.ProvisionExternalLoginAsync( + info.LoginProvider, + info.ProviderKey, + model.Email, + emailVerified); + + if (!result.Succeeded || result.UserId is null) + { + foreach (var error in result.Errors) + { + AddLocalizedModelError(error); + } + + return View(model); + } + + await _userConsentService.RecordRegistrationConsentAsync( + result.UserId.Value, + "google", + GetRequestIpAddress(), + Request.Headers.UserAgent.ToString()); + + var user = await _userManager.FindByIdAsync(result.UserId.Value.ToString()); + if (user is null) + { + AddLocalizedModelError("Unable to locate the linked account."); + await SetExternalLoginAvailabilityAsync(); + return View("Login", new LoginViewModel { ReturnUrl = model.ReturnUrl, RememberMe = model.RememberMe }); + } + + if (user.DisabledAt.HasValue) + { + AddLocalizedModelError("Account is disabled."); + await SetExternalLoginAvailabilityAsync(); + return View("Login", new LoginViewModel { ReturnUrl = model.ReturnUrl, RememberMe = model.RememberMe }); + } + + await _signInManager.SignInAsync(user, model.RememberMe, info.LoginProvider); + await UpdateSignInMetadataAsync(user); + + if (IsAllowedReturnUrl(model.ReturnUrl, ReturnUrlPurpose.Login)) + { + return Redirect(model.ReturnUrl!); + } + + return RedirectToAction("Index", "Home", new { area = string.Empty }); + } + [HttpGet] [AllowAnonymous] public async Task Logout(string? returnUrl = null) @@ -242,7 +332,7 @@ public class AccountController : Controller { foreach (var error in result.Errors) { - ModelState.AddModelError(string.Empty, error.Description); + AddLocalizedModelError(error.Description); } return View(model); @@ -283,7 +373,7 @@ public class AccountController : Controller { foreach (var error in result.Errors) { - ModelState.AddModelError(string.Empty, error); + AddLocalizedModelError(error); } await SetExternalLoginAvailabilityAsync(); return View(model); @@ -292,6 +382,11 @@ public class AccountController : Controller var user = await _userManager.FindByEmailAsync(model.Email); if (user is not null) { + await _userConsentService.RecordRegistrationConsentAsync( + user.Id, + "local", + GetRequestIpAddress(), + Request.Headers.UserAgent.ToString()); await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl()); } @@ -353,7 +448,7 @@ public class AccountController : Controller { foreach (var error in result.Errors) { - ModelState.AddModelError(string.Empty, error.Description); + AddLocalizedModelError(error.Description); } return View(model); } @@ -412,6 +507,16 @@ public class AccountController : Controller private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}"; + private void AddLocalizedModelError(string message, params object[] arguments) + { + ModelState.AddModelError(string.Empty, _localizer[message, arguments]); + } + + private string? GetRequestIpAddress() + { + return HttpContext.Connection.RemoteIpAddress?.ToString(); + } + private async Task SetExternalLoginAvailabilityAsync() { ViewData["GoogleLoginEnabled"] = await _authenticationSchemeProvider.GetSchemeAsync("Google") is not null; diff --git a/src/MemberCenter.Web/Models/Account/ChangePasswordViewModel.cs b/src/MemberCenter.Web/Models/Account/ChangePasswordViewModel.cs index 5db017a..f651a3b 100644 --- a/src/MemberCenter.Web/Models/Account/ChangePasswordViewModel.cs +++ b/src/MemberCenter.Web/Models/Account/ChangePasswordViewModel.cs @@ -4,16 +4,19 @@ namespace MemberCenter.Web.Models.Account; public sealed class ChangePasswordViewModel { - [Required] + [Display(Name = "CurrentPassword")] + [Required(ErrorMessage = "The {0} field is required.")] [DataType(DataType.Password)] public string CurrentPassword { get; set; } = string.Empty; - [Required] + [Display(Name = "NewPassword")] + [Required(ErrorMessage = "The {0} field is required.")] [DataType(DataType.Password)] public string NewPassword { get; set; } = string.Empty; - [Required] - [Compare(nameof(NewPassword))] + [Display(Name = "ConfirmPassword")] + [Required(ErrorMessage = "The {0} field is required.")] + [Compare(nameof(NewPassword), ErrorMessage = "The {0} and {1} fields do not match.")] [DataType(DataType.Password)] public string ConfirmPassword { get; set; } = string.Empty; } diff --git a/src/MemberCenter.Web/Models/Account/ExternalRegisterConfirmationViewModel.cs b/src/MemberCenter.Web/Models/Account/ExternalRegisterConfirmationViewModel.cs new file mode 100644 index 0000000..70ee6ea --- /dev/null +++ b/src/MemberCenter.Web/Models/Account/ExternalRegisterConfirmationViewModel.cs @@ -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; } +} diff --git a/src/MemberCenter.Web/Models/Account/ForgotPasswordViewModel.cs b/src/MemberCenter.Web/Models/Account/ForgotPasswordViewModel.cs index cfae1a7..bf21b0f 100644 --- a/src/MemberCenter.Web/Models/Account/ForgotPasswordViewModel.cs +++ b/src/MemberCenter.Web/Models/Account/ForgotPasswordViewModel.cs @@ -4,7 +4,8 @@ namespace MemberCenter.Web.Models.Account; public sealed class ForgotPasswordViewModel { - [Required] - [EmailAddress] + [Display(Name = "Email")] + [Required(ErrorMessage = "The {0} field is required.")] + [EmailAddress(ErrorMessage = "The {0} field is not a valid e-mail address.")] public string Email { get; set; } = string.Empty; } diff --git a/src/MemberCenter.Web/Models/Account/LoginViewModel.cs b/src/MemberCenter.Web/Models/Account/LoginViewModel.cs index ed8292b..e7c7485 100644 --- a/src/MemberCenter.Web/Models/Account/LoginViewModel.cs +++ b/src/MemberCenter.Web/Models/Account/LoginViewModel.cs @@ -4,11 +4,13 @@ namespace MemberCenter.Web.Models.Account; public sealed class LoginViewModel { - [Required] - [EmailAddress] + [Display(Name = "Email")] + [Required(ErrorMessage = "The {0} field is required.")] + [EmailAddress(ErrorMessage = "The {0} field is not a valid e-mail address.")] public string Email { get; set; } = string.Empty; - [Required] + [Display(Name = "Password")] + [Required(ErrorMessage = "The {0} field is required.")] [DataType(DataType.Password)] public string Password { get; set; } = string.Empty; diff --git a/src/MemberCenter.Web/Models/Account/RegisterViewModel.cs b/src/MemberCenter.Web/Models/Account/RegisterViewModel.cs index 8596b8c..6a62549 100644 --- a/src/MemberCenter.Web/Models/Account/RegisterViewModel.cs +++ b/src/MemberCenter.Web/Models/Account/RegisterViewModel.cs @@ -4,16 +4,19 @@ namespace MemberCenter.Web.Models.Account; public sealed class RegisterViewModel { - [Required] - [EmailAddress] + [Display(Name = "Email")] + [Required(ErrorMessage = "The {0} field is required.")] + [EmailAddress(ErrorMessage = "The {0} field is not a valid e-mail address.")] public string Email { get; set; } = string.Empty; - [Required] + [Display(Name = "Password")] + [Required(ErrorMessage = "The {0} field is required.")] [DataType(DataType.Password)] public string Password { get; set; } = string.Empty; - [Required] - [Compare(nameof(Password))] + [Display(Name = "ConfirmPassword")] + [Required(ErrorMessage = "The {0} field is required.")] + [Compare(nameof(Password), ErrorMessage = "The {0} and {1} fields do not match.")] [DataType(DataType.Password)] public string ConfirmPassword { get; set; } = string.Empty; diff --git a/src/MemberCenter.Web/Models/Account/ResetPasswordViewModel.cs b/src/MemberCenter.Web/Models/Account/ResetPasswordViewModel.cs index 4febeee..385e2a9 100644 --- a/src/MemberCenter.Web/Models/Account/ResetPasswordViewModel.cs +++ b/src/MemberCenter.Web/Models/Account/ResetPasswordViewModel.cs @@ -4,19 +4,23 @@ namespace MemberCenter.Web.Models.Account; public sealed class ResetPasswordViewModel { - [Required] - [EmailAddress] + [Display(Name = "Email")] + [Required(ErrorMessage = "The {0} field is required.")] + [EmailAddress(ErrorMessage = "The {0} field is not a valid e-mail address.")] public string Email { get; set; } = string.Empty; - [Required] + [Display(Name = "Token")] + [Required(ErrorMessage = "The {0} field is required.")] public string Token { get; set; } = string.Empty; - [Required] + [Display(Name = "NewPassword")] + [Required(ErrorMessage = "The {0} field is required.")] [DataType(DataType.Password)] public string NewPassword { get; set; } = string.Empty; - [Required] - [Compare(nameof(NewPassword))] + [Display(Name = "ConfirmPassword")] + [Required(ErrorMessage = "The {0} field is required.")] + [Compare(nameof(NewPassword), ErrorMessage = "The {0} and {1} fields do not match.")] [DataType(DataType.Password)] public string ConfirmPassword { get; set; } = string.Empty; } diff --git a/src/MemberCenter.Web/Models/Admin/EmailBlacklistFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/EmailBlacklistFormViewModel.cs index 93ce967..2bf5ca9 100644 --- a/src/MemberCenter.Web/Models/Admin/EmailBlacklistFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Admin/EmailBlacklistFormViewModel.cs @@ -4,10 +4,12 @@ namespace MemberCenter.Web.Models.Admin; public sealed class EmailBlacklistFormViewModel { - [Required] - [EmailAddress] + [Display(Name = "Email")] + [Required(ErrorMessage = "The {0} field is required.")] + [EmailAddress(ErrorMessage = "The {0} field is not a valid e-mail address.")] public string Email { get; set; } = string.Empty; - [Required] + [Display(Name = "Reason")] + [Required(ErrorMessage = "The {0} field is required.")] public string Reason { get; set; } = string.Empty; } diff --git a/src/MemberCenter.Web/Models/Admin/NewsletterListFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/NewsletterListFormViewModel.cs index d5ff52d..bf8253e 100644 --- a/src/MemberCenter.Web/Models/Admin/NewsletterListFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Admin/NewsletterListFormViewModel.cs @@ -6,15 +6,18 @@ public sealed class NewsletterListFormViewModel { public Guid? Id { get; set; } - [Required] + [Display(Name = "Tenant")] + [Required(ErrorMessage = "The {0} field is required.")] public Guid TenantId { get; set; } public IReadOnlyList Tenants { get; set; } = Array.Empty(); - [Required] + [Display(Name = "Name")] + [Required(ErrorMessage = "The {0} field is required.")] public string Name { get; set; } = string.Empty; - [Required] + [Display(Name = "Status")] + [Required(ErrorMessage = "The {0} field is required.")] public string Status { get; set; } = "active"; } diff --git a/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs index ba07126..dd23fc5 100644 --- a/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs @@ -6,13 +6,16 @@ public sealed class OAuthClientFormViewModel { public Guid? TenantId { get; set; } - [Required] + [Display(Name = "Name")] + [Required(ErrorMessage = "The {0} field is required.")] public string Name { get; set; } = string.Empty; - [Required] + [Display(Name = "Type")] + [Required(ErrorMessage = "The {0} field is required.")] public string ClientType { get; set; } = "public"; - [Required] + [Display(Name = "Usage")] + [Required(ErrorMessage = "The {0} field is required.")] public string Usage { get; set; } = "tenant_api"; public string RedirectUris { get; set; } = string.Empty; diff --git a/src/MemberCenter.Web/Models/Admin/TenantFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/TenantFormViewModel.cs index 8f196e3..e3461b6 100644 --- a/src/MemberCenter.Web/Models/Admin/TenantFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Admin/TenantFormViewModel.cs @@ -6,12 +6,14 @@ public sealed class TenantFormViewModel { public Guid? Id { get; set; } - [Required] + [Display(Name = "Name")] + [Required(ErrorMessage = "The {0} field is required.")] public string Name { get; set; } = string.Empty; public string Domains { get; set; } = string.Empty; - [Required] + [Display(Name = "Status")] + [Required(ErrorMessage = "The {0} field is required.")] public string Status { get; set; } = "active"; public string? SendEngineWebhookClientId { get; set; } diff --git a/src/MemberCenter.Web/Models/Newsletter/ConfirmViewModel.cs b/src/MemberCenter.Web/Models/Newsletter/ConfirmViewModel.cs index 206c47b..0d852b2 100644 --- a/src/MemberCenter.Web/Models/Newsletter/ConfirmViewModel.cs +++ b/src/MemberCenter.Web/Models/Newsletter/ConfirmViewModel.cs @@ -4,6 +4,7 @@ namespace MemberCenter.Web.Models.Newsletter; public sealed class ConfirmViewModel { - [Required] + [Display(Name = "Token")] + [Required(ErrorMessage = "The {0} field is required.")] public string Token { get; set; } = string.Empty; } diff --git a/src/MemberCenter.Web/Models/Newsletter/UnsubscribeViewModel.cs b/src/MemberCenter.Web/Models/Newsletter/UnsubscribeViewModel.cs index e209c65..9766611 100644 --- a/src/MemberCenter.Web/Models/Newsletter/UnsubscribeViewModel.cs +++ b/src/MemberCenter.Web/Models/Newsletter/UnsubscribeViewModel.cs @@ -4,6 +4,7 @@ namespace MemberCenter.Web.Models.Newsletter; public sealed class UnsubscribeViewModel { - [Required] + [Display(Name = "Token")] + [Required(ErrorMessage = "The {0} field is required.")] public string Token { get; set; } = string.Empty; } diff --git a/src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs b/src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs index 497b858..a74bb2a 100644 --- a/src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs @@ -6,42 +6,53 @@ public sealed class AddressFormViewModel { public Guid? Id { get; set; } - [Required] - [StringLength(100)] + [Display(Name = "Label")] + [Required(ErrorMessage = "The {0} field is required.")] + [StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string Label { get; set; } = "home"; - [Required] - [StringLength(100)] + [Display(Name = "Recipient name")] + [Required(ErrorMessage = "The {0} field is required.")] + [StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string RecipientName { get; set; } = string.Empty; - [Required] - [StringLength(50)] + [Display(Name = "Recipient phone")] + [Required(ErrorMessage = "The {0} field is required.")] + [StringLength(50, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string RecipientPhone { get; set; } = string.Empty; - [Required] - [StringLength(2, MinimumLength = 2)] + [Display(Name = "Country code")] + [Required(ErrorMessage = "The {0} field is required.")] + [StringLength(2, MinimumLength = 2, ErrorMessage = "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.")] public string CountryCode { get; set; } = "TW"; - [StringLength(20)] + [Display(Name = "Postal code")] + [StringLength(20, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? PostalCode { get; set; } - [StringLength(100)] + [Display(Name = "State / region")] + [StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? StateRegion { get; set; } - [StringLength(100)] + [Display(Name = "City")] + [StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? City { get; set; } - [StringLength(100)] + [Display(Name = "District")] + [StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? District { get; set; } - [Required] - [StringLength(255)] + [Display(Name = "Address line 1")] + [Required(ErrorMessage = "The {0} field is required.")] + [StringLength(255, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string AddressLine1 { get; set; } = string.Empty; - [StringLength(255)] + [Display(Name = "Address line 2")] + [StringLength(255, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? AddressLine2 { get; set; } - [StringLength(200)] + [Display(Name = "Company name")] + [StringLength(200, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? CompanyName { get; set; } public bool IsDefault { get; set; } diff --git a/src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs b/src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs index 17826ce..40a4c2c 100644 --- a/src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs +++ b/src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs @@ -4,48 +4,62 @@ namespace MemberCenter.Web.Models.Profile; public sealed class ProfileViewModel { - [Required] - [StringLength(100)] + [Display(Name = "Last name")] + [Required(ErrorMessage = "The {0} field is required.")] + [StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string LastName { get; set; } = string.Empty; - [Required] - [StringLength(100)] + [Display(Name = "First name")] + [Required(ErrorMessage = "The {0} field is required.")] + [StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string FirstName { get; set; } = string.Empty; - [StringLength(100)] + [Display(Name = "Nickname")] + [StringLength(100, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? NickName { get; set; } - [StringLength(50)] + [Display(Name = "Mobile phone")] + [StringLength(50, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? MobilePhone { get; set; } - [StringLength(50)] + [Display(Name = "Landline phone")] + [StringLength(50, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? LandlinePhone { get; set; } + [Display(Name = "Date of birth")] [DataType(DataType.Date)] public DateOnly? DateOfBirth { get; set; } - [Required] + [Display(Name = "Gender")] + [Required(ErrorMessage = "The {0} field is required.")] public string Gender { get; set; } = "unspecified"; - [StringLength(200)] + [Display(Name = "Company name")] + [StringLength(200, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? CompanyName { get; set; } - [StringLength(200)] + [Display(Name = "Department")] + [StringLength(200, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? Department { get; set; } - [StringLength(200)] + [Display(Name = "Job title")] + [StringLength(200, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? JobTitle { get; set; } - [StringLength(50)] + [Display(Name = "Company phone")] + [StringLength(50, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? CompanyPhone { get; set; } - [StringLength(32)] + [Display(Name = "Tax ID")] + [StringLength(32, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? TaxId { get; set; } - [StringLength(200)] + [Display(Name = "Invoice title")] + [StringLength(200, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? InvoiceTitle { get; set; } - [StringLength(1000)] + [Display(Name = "Remark")] + [StringLength(1000, ErrorMessage = "The field {0} must be a string with a maximum length of {1}.")] public string? Remark { get; set; } public string Email { get; set; } = string.Empty; diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index 1676dd5..3421fe1 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -15,6 +15,7 @@ using MemberCenter.Infrastructure.Configuration; using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Persistence; using MemberCenter.Infrastructure.Services; +using MemberCenter.Web.Localization; using MemberCenter.Web.Services; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -38,8 +39,12 @@ if (string.IsNullOrWhiteSpace(connectionString)) connectionString = "Host=localhost;Database=member_center;Username=postgres;Password=postgres"; } -var dataProtectionCertificate = CertificateLoader.LoadFromConfiguration( - builder.Configuration, "DataProtection:Certificate", required: !builder.Environment.IsDevelopment()); +var dataProtectionCertificate = await CertificateLoader.LoadFromConfigurationOrDatabaseAsync( + builder.Configuration, + connectionString, + "DataProtection:Certificate", + CertificateLoader.DataProtectionKey, + required: !builder.Environment.IsDevelopment()); var previousDataProtectionCertificates = CertificateLoader.LoadPreviousFromConfiguration( builder.Configuration, "DataProtection:Certificate"); @@ -76,6 +81,7 @@ builder.Services options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(identitySecurity.Lockout.DefaultLockoutMinutes); }) .AddEntityFrameworkStores() + .AddErrorDescriber() .AddDefaultTokenProviders(); builder.Services.Configure(options => @@ -87,14 +93,18 @@ var googleClientId = builder.Configuration["Authentication:Google:ClientId"] ?? Environment.GetEnvironmentVariable("Authentication__Google__ClientId"); var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? Environment.GetEnvironmentVariable("Authentication__Google__ClientSecret"); +var googleLoginEnabled = false; var authenticationBuilder = builder.Services.AddAuthentication(); if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret)) { + googleLoginEnabled = true; + var configuredGoogleClientId = googleClientId; + var configuredGoogleClientSecret = googleClientSecret; authenticationBuilder.AddGoogle(options => { - options.ClientId = googleClientId; - options.ClientSecret = googleClientSecret; + options.ClientId = configuredGoogleClientId; + options.ClientSecret = configuredGoogleClientSecret; }); } @@ -167,6 +177,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(services => services.GetRequiredService()); @@ -191,7 +202,10 @@ builder.Services.AddControllersWithViews(options => options.Filters.Add(new Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute()); }) .AddViewLocalization() - .AddDataAnnotationsLocalization(); + .AddDataAnnotationsLocalization(options => + { + options.DataAnnotationLocalizerProvider = (_, factory) => factory.Create(typeof(SharedResource)); + }); builder.Services.AddHttpContextAccessor(); var supportedCultures = new[] @@ -232,8 +246,11 @@ app.Use(async (context, next) => context.Response.OnStarting(() => { var headers = context.Response.Headers; + var formAction = googleLoginEnabled + ? "'self' https://accounts.google.com" + : "'self'"; headers.TryAdd("Content-Security-Policy", - "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"); + $"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action {formAction}; frame-ancestors 'none'"); headers.TryAdd("X-Content-Type-Options", "nosniff"); headers.TryAdd("X-Frame-Options", "DENY"); headers.TryAdd("Referrer-Policy", "no-referrer"); diff --git a/src/MemberCenter.Web/Resources/Localization/SharedResource.zh-TW.resx b/src/MemberCenter.Web/Resources/Localization/SharedResource.zh-TW.resx index d1e3b6d..40d1733 100644 --- a/src/MemberCenter.Web/Resources/Localization/SharedResource.zh-TW.resx +++ b/src/MemberCenter.Web/Resources/Localization/SharedResource.zh-TW.resx @@ -35,6 +35,7 @@ 建立 建立新帳號 使用 Email 建立會員帳號。 + 完成註冊 現人數 現總數 儀表板摘要 @@ -48,6 +49,7 @@ 輸入你的 Email 輸入你的密碼 再次輸入你的密碼 + Email 無法取得 編輯電子報 編輯 OAuth Client 編輯租戶 @@ -58,6 +60,7 @@ 帳號凍結 凍結 性別 + Google 帳號 主頁 我已閱讀並同意 語言 @@ -99,6 +102,7 @@ 記住我 註冊 使用 Google 註冊 + 請確認並同意條款後建立帳號。 重新寄送驗證信 儲存 搜尋 @@ -263,4 +267,53 @@ 未指定 Webhook Client ID 縮放 + 服務條款與隱私權政策 + 確認密碼 + 目前密碼 + 新密碼 + 返回網址 + 記住我 + {0} 欄位為必填。 + {0} 必須是有效的 Email。 + {0} 必須是有效的 Email。 + {0} 與 {1} 不相符。 + {0} 與 {1} 不相符。 + {0} 不可超過 {1} 個字元。 + {0} 長度必須介於 {2} 到 {1} 個字元。 + {0} 必須介於 {1} 到 {2} 之間。 + {0} 的值無效。 + {0} 的值無效。 + 請提供 {0}。 + {1} 的值「{0}」無效。 + 註冊前必須同意服務條款與隱私權政策。 + 帳號已停用。 + 帳號暫時鎖定,請稍後再試。 + 登入失敗,請確認 Email 與密碼。 + {0} 登入尚未設定。 + 外部登入失敗:{0} + 無法取得外部登入資訊。 + 找不到已連結的帳號。 + 外部登入未提供 Email。 + 發生未知錯誤。 + 資料已被其他操作修改,請重新整理後再試。 + 密碼不正確。 + Token 無效。 + 這個登入方式已被其他帳號使用。 + 使用者名稱「{0}」無效,只能包含英文字母或數字。 + Email「{0}」無效。 + 使用者名稱「{0}」已被使用。 + Email「{0}」已被使用。 + 角色名稱「{0}」無效。 + 角色名稱「{0}」已被使用。 + 使用者已設定密碼。 + 這個使用者未啟用鎖定功能。 + 使用者已在「{0}」角色中。 + 使用者不在「{0}」角色中。 + 密碼至少需要 {0} 個字元。 + 密碼至少需要使用 {0} 種不同字元。 + 密碼至少需要一個非英數字元。 + 密碼至少需要一個數字(0-9)。 + 密碼至少需要一個小寫英文字母(a-z)。 + 密碼至少需要一個大寫英文字母(A-Z)。 + 復原碼驗證失敗。 diff --git a/src/MemberCenter.Web/Services/LocalizedIdentityErrorDescriber.cs b/src/MemberCenter.Web/Services/LocalizedIdentityErrorDescriber.cs new file mode 100644 index 0000000..4190431 --- /dev/null +++ b/src/MemberCenter.Web/Services/LocalizedIdentityErrorDescriber.cs @@ -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 _localizer; + + public LocalizedIdentityErrorDescriber(IStringLocalizer 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] + }; +} diff --git a/src/MemberCenter.Web/Views/Account/ExternalRegisterConfirmation.cshtml b/src/MemberCenter.Web/Views/Account/ExternalRegisterConfirmation.cshtml new file mode 100644 index 0000000..ff50a5b --- /dev/null +++ b/src/MemberCenter.Web/Views/Account/ExternalRegisterConfirmation.cshtml @@ -0,0 +1,40 @@ +@model MemberCenter.Web.Models.Account.ExternalRegisterConfirmationViewModel +@{ + Layout = "_AuthLayout"; + ViewData["Title"] = L["Complete registration"]; +} + +
+
+

@L["Complete registration"]

+

@L["Review and accept the terms to create your account."]

+
+ @L["Back to login"] +
+ +
+ +
+ + + + + + + + + + + +
diff --git a/src/MemberCenter.Web/Views/Account/ForgotPasswordConfirmation.cshtml b/src/MemberCenter.Web/Views/Account/ForgotPasswordConfirmation.cshtml index 6ad6840..74cff0b 100644 --- a/src/MemberCenter.Web/Views/Account/ForgotPasswordConfirmation.cshtml +++ b/src/MemberCenter.Web/Views/Account/ForgotPasswordConfirmation.cshtml @@ -5,3 +5,4 @@

@L["Password Reset"]

@L["If the email exists, a password reset email has been sent."]

+@L["Back to login"] diff --git a/src/MemberCenter.Web/wwwroot/css/site.css b/src/MemberCenter.Web/wwwroot/css/site.css index 8933d5e..969cc18 100644 --- a/src/MemberCenter.Web/wwwroot/css/site.css +++ b/src/MemberCenter.Web/wwwroot/css/site.css @@ -385,6 +385,20 @@ span.field-validation-error { gap: 0.6rem; } +.auth-external-account { + display: grid; + gap: 0.25rem; + padding: 0.75rem 0; + border-top: 1px solid #c9c7c1; + border-bottom: 1px solid #c9c7c1; + color: #111827; + font-size: 0.78rem; +} + +.auth-external-account strong { + font-size: 0.95rem; +} + .form-field { display: grid; gap: 0.28rem;