diff --git a/.env.example b/.env.example
index 7397fdd..dcd82f7 100644
--- a/.env.example
+++ b/.env.example
@@ -15,9 +15,15 @@ PORT=4173
# REDIS_SESSION_DB=1
# REDIS_SESSION_PREFIX=exam-information:auth
# AUTH_SESSION_TTL_SECONDS=28800
+# AUTH_LOGIN_CHALLENGE_TTL_SECONDS=300
+# AUTH_TOTP_SETUP_TTL_SECONDS=600
# 如需让认证状态使用另一台 Redis,可设置独立地址;URL 中可直接指定逻辑 DB。
# REDIS_SESSION_URL=rediss://session-redis.example.com:6379/1
+# 原生 ASP.NET Core 认证切换。迁移期间默认关闭;生产环境开启时必须配置共享 Redis,
+# 以便尚未迁移的 Node 受保护接口识别由 ASP.NET Core 创建的会话。
+AUTH_NATIVE_ENABLED=false
+
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=Admin123!
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f7cd781..db38a4a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,7 +6,9 @@
+
+
diff --git a/MIGRATION.md b/MIGRATION.md
index e0a1435..92fb7e7 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -10,7 +10,7 @@
- [x] 存活与迁移就绪检查
- [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取)
- [x] 招生公示与 HMAC 文书验真公开接口
-- [ ] 登录、Session 与 TOTP
+- [x] 登录、自主注册、Session 与 TOTP(兼容开关默认关闭)
- [ ] 考生业务
- [ ] 管理后台、审批流和考务编排
- [ ] 招生录取
@@ -39,6 +39,14 @@ dotnet run --project .\src\Eis.Web\Eis.Web.csproj
可通过配置 `LegacyNode:Enabled=false` 禁用兼容转发;此时尚未迁移的 API 会返回 `501`。
+认证域的 ASP.NET Core 原生实现已经覆盖 `/api/auth/*`,包括现有 PBKDF2 密码、`hz_session` Cookie、登录挑战、TOTP、防重放、恢复码和自主注册。迁移期间默认仍由 Node 处理认证;显式设置以下变量后切换到原生实现:
+
+```powershell
+$env:AUTH_NATIVE_ENABLED = 'true'
+```
+
+开发环境未配置 Redis 时可以使用进程内状态独立验证原生认证。生产环境以及仍需访问 Node 受保护接口的联调环境必须配置 `REDIS_URL` 或 `REDIS_SESSION_URL`;两个运行时会复用相同逻辑库和 `exam-information:auth` 键前缀,从而共享登录会话。`GET /health/migration` 会报告 `authentication.nativeEnabled`、状态后端和跨运行时会话共享能力。
+
完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
```powershell
diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1
index e119f68..2abc6d7 100644
--- a/scripts/smoke-dotnet-migration.ps1
+++ b/scripts/smoke-dotnet-migration.ps1
@@ -9,6 +9,7 @@ $smokeArtifactRoot = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot 'a
$testDirectory = Join-Path $smokeArtifactRoot ("eis-migration-smoke-{0}" -f [guid]::NewGuid().ToString('N'))
$nodeProcess = $null
$dotnetProcess = $null
+$nativeAuthProcess = $null
function Get-AvailableTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
@@ -88,8 +89,10 @@ try {
$legacyPort = Get-AvailableTcpPort
$webPort = Get-AvailableTcpPort
+ $nativeAuthPort = Get-AvailableTcpPort
$legacyBaseUrl = "http://127.0.0.1:$legacyPort"
$webBaseUrl = "http://127.0.0.1:$webPort"
+ $nativeAuthBaseUrl = "http://127.0.0.1:$nativeAuthPort"
$nodeExecutable = (Get-Command node.exe -ErrorAction Stop).Source
$dotnetExecutable = (Get-Command dotnet.exe -ErrorAction Stop).Source
@@ -120,6 +123,20 @@ try {
}
$seedProcess.Dispose()
+ $registrationSwitchProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
+ 'tests/helpers/enable-self-registration.mjs', $smokeDatabasePath
+ ) -Environment $nodeEnvironment
+ if (-not $registrationSwitchProcess.WaitForExit(10000)) {
+ $registrationSwitchProcess.Kill($true)
+ throw 'Timed out while enabling self-registration in the smoke-test database'
+ }
+ $registrationSwitchOutput = $registrationSwitchProcess.StandardOutput.ReadToEnd()
+ $registrationSwitchError = $registrationSwitchProcess.StandardError.ReadToEnd()
+ if ($registrationSwitchProcess.ExitCode -ne 0) {
+ throw "Could not enable self-registration in the smoke-test database`n$registrationSwitchOutput`n$registrationSwitchError"
+ }
+ $registrationSwitchProcess.Dispose()
+
$nodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('server.mjs') -Environment $nodeEnvironment
$projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj'
@@ -280,6 +297,125 @@ try {
throw 'Native document verification did not reject an invalid code'
}
+ $nativeAuthProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @(
+ 'run',
+ '--project', $projectPath,
+ '--configuration', 'Release',
+ '--no-build',
+ '--no-launch-profile',
+ '--',
+ '--urls', $nativeAuthBaseUrl
+ ) -Environment @{
+ ASPNETCORE_ENVIRONMENT = 'Development'
+ AUTH_NATIVE_ENABLED = 'true'
+ LegacyNode__Enabled = 'true'
+ LegacyNode__BaseUrl = $legacyBaseUrl
+ DATABASE_CLIENT = 'sqlite'
+ SQLITE_PATH = $smokeDatabasePath
+ TOTP_ENCRYPTION_KEY = 'migration-smoke-totp-key-32-characters-minimum'
+ DOCUMENT_VERIFICATION_SECRET = 'migration-smoke-document-key-32-characters-minimum'
+ REDIS_URL = ''
+ REDIS_SESSION_URL = ''
+ }
+ Wait-ForUrl -Uri "$nativeAuthBaseUrl/health/live" -Processes @($nodeProcess, $nativeAuthProcess)
+
+ $registrationSchool = @($homePayload.schools | Select-Object -First 1)[0]
+ $registrationClass = @($homePayload.classes | Where-Object schoolId -eq $registrationSchool.id | Select-Object -First 1)[0]
+ if ($null -eq $registrationSchool -or $null -eq $registrationClass) {
+ throw 'Seed data did not provide a source school and class for native registration'
+ }
+ $registrationBody = @{
+ name = '原生迁移注册考生'
+ gender = '女'
+ password = 'Registration456!'
+ schoolId = $registrationSchool.id
+ classId = $registrationClass.id
+ } | ConvertTo-Json -Compress
+ $registrationResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/register" -Method Post -ContentType 'application/json' -Body $registrationBody
+ if ($registrationResponse.StatusCode -ne 201 -or $registrationResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
+ throw 'Self-registration request did not use the native ASP.NET Core endpoint'
+ }
+ $registration = $registrationResponse.Content | ConvertFrom-Json
+ $registeredLoginBody = @{ username = $registration.registrationNumber; password = 'Registration456!' } | ConvertTo-Json -Compress
+ $registeredLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $registeredLoginBody
+ if ($registeredLogin.user.candidateNumber -ne $registration.registrationNumber) {
+ throw 'Native self-registration did not create a usable candidate account'
+ }
+
+ $nativeSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
+ $nativeLoginResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $nativeSession
+ if ($nativeLoginResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
+ throw 'Authentication login request did not use the native ASP.NET Core endpoint'
+ }
+ $nativeLogin = $nativeLoginResponse.Content | ConvertFrom-Json
+ if ($nativeLogin.ok -ne $true -or $nativeLogin.user.username -ne 'admin') {
+ throw 'Native authentication could not verify the existing Node PBKDF2 account'
+ }
+
+ $nativeMe = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/me" -WebSession $nativeSession
+ if ($nativeMe.user.username -ne 'admin' -or $nativeMe.permissions[0] -ne '*') {
+ throw 'Native authentication did not preserve the session or administrator projection'
+ }
+
+ $totpSetupBody = @{ currentPassword = '12345678' } | ConvertTo-Json -Compress
+ $totpSetup = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/totp/setup" -Method Post -ContentType 'application/json' -Body $totpSetupBody -WebSession $nativeSession
+ if ($totpSetup.qrCode -notmatch '^data:image/png;base64,' -or $totpSetup.uri -notmatch '^otpauth://totp/') {
+ throw 'Native TOTP setup did not return a local PNG QR code and otpauth URI'
+ }
+
+ $totpCodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @(
+ 'tests/helpers/current-totp-code.mjs', $totpSetup.secret
+ ) -Environment $nodeEnvironment
+ if (-not $totpCodeProcess.WaitForExit(10000)) {
+ $totpCodeProcess.Kill($true)
+ throw 'Timed out while generating the native TOTP smoke-test code'
+ }
+ $totpCode = $totpCodeProcess.StandardOutput.ReadToEnd().Trim()
+ $totpCodeError = $totpCodeProcess.StandardError.ReadToEnd()
+ if ($totpCodeProcess.ExitCode -ne 0 -or -not $totpCode) {
+ throw "Could not generate the native TOTP smoke-test code`n$totpCodeError"
+ }
+ $totpCodeProcess.Dispose()
+
+ $totpEnableBody = @{ code = $totpCode } | ConvertTo-Json -Compress
+ $totpEnable = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/totp/enable" -Method Post -ContentType 'application/json' -Body $totpEnableBody -WebSession $nativeSession
+ if ($totpEnable.user.totpEnabled -ne $true -or $totpEnable.recoveryCodes.Count -ne 8) {
+ throw 'Native TOTP enablement did not persist security state or issue eight recovery codes'
+ }
+
+ Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/logout" -Method Post -WebSession $nativeSession | Out-Null
+ $totpPasswordLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody
+ if ($totpPasswordLogin.requiresTotp -ne $true -or -not $totpPasswordLogin.challenge) {
+ throw 'Native password login bypassed enabled TOTP'
+ }
+
+ $totpLoginSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
+ $recoveryLoginBody = @{ challenge = $totpPasswordLogin.challenge; code = $totpEnable.recoveryCodes[0] } | ConvertTo-Json -Compress
+ $totpLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login/totp" -Method Post -ContentType 'application/json' -Body $recoveryLoginBody -WebSession $totpLoginSession
+ if ($totpLogin.usedRecoveryCode -ne $true -or $totpLogin.user.username -ne 'admin') {
+ throw 'Native TOTP recovery-code login did not create a session'
+ }
+
+ $totpStatus = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/totp" -WebSession $totpLoginSession
+ if ($totpStatus.enabled -ne $true -or $totpStatus.recoveryCodesRemaining -ne 7) {
+ throw 'Native TOTP recovery code was not consumed exactly once'
+ }
+
+ $disableBody = @{ currentPassword = '12345678'; code = $totpEnable.recoveryCodes[1] } | ConvertTo-Json -Compress
+ $disabled = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/totp/disable" -Method Post -ContentType 'application/json' -Body $disableBody -WebSession $totpLoginSession
+ if ($disabled.user.totpEnabled -ne $false) {
+ throw 'Native TOTP disable endpoint did not clear the security state'
+ }
+
+ $changePasswordBody = @{ currentPassword = '12345678'; newPassword = 'MigrationAuth456!' } | ConvertTo-Json -Compress
+ Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/change-password" -Method Post -ContentType 'application/json' -Body $changePasswordBody -WebSession $totpLoginSession | Out-Null
+ Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/logout" -Method Post -WebSession $totpLoginSession | Out-Null
+ $changedLoginBody = @{ username = 'admin'; password = 'MigrationAuth456!' } | ConvertTo-Json -Compress
+ $changedLogin = Invoke-RestMethod -Uri "$nativeAuthBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $changedLoginBody
+ if ($changedLogin.user.username -ne 'admin') {
+ throw 'Native password change did not create a Node-compatible PBKDF2 hash'
+ }
+
[pscustomobject]@{
AspNetCoreHost = 'passed'
StaticAssets = 'passed'
@@ -288,10 +424,11 @@ try {
NativePublicApi = 'passed'
PublicParity = 'passed'
DocumentCodes = 'passed'
+ NativeAuthentication = 'passed'
} | Format-List
}
finally {
- foreach ($process in @($dotnetProcess, $nodeProcess)) {
+ foreach ($process in @($nativeAuthProcess, $dotnetProcess, $nodeProcess)) {
if ($null -ne $process -and -not $process.HasExited) {
$process.Kill($true)
$process.WaitForExit()
diff --git a/src/Eis.Application/Authentication/IAuthenticationService.cs b/src/Eis.Application/Authentication/IAuthenticationService.cs
new file mode 100644
index 0000000..e128825
--- /dev/null
+++ b/src/Eis.Application/Authentication/IAuthenticationService.cs
@@ -0,0 +1,57 @@
+using System.Text.Json.Nodes;
+
+namespace Eis.Application.Authentication;
+
+public sealed record AuthenticationEndpointResult(int StatusCode, JsonObject Body, string? SetCookie = null);
+
+public interface IAuthenticationService
+{
+ Task RegisterAsync(
+ string name,
+ string gender,
+ string password,
+ string schoolId,
+ string classId,
+ CancellationToken cancellationToken);
+
+ Task GetCurrentUserAsync(string sessionToken, CancellationToken cancellationToken);
+
+ Task LoginAsync(string username, string password, CancellationToken cancellationToken);
+
+ Task CompleteTotpLoginAsync(
+ string challenge,
+ string code,
+ CancellationToken cancellationToken);
+
+ Task ChangePasswordAsync(
+ string sessionToken,
+ string currentPassword,
+ string newPassword,
+ CancellationToken cancellationToken);
+
+ Task GetTotpStatusAsync(string sessionToken, CancellationToken cancellationToken);
+
+ Task BeginTotpSetupAsync(
+ string sessionToken,
+ string currentPassword,
+ CancellationToken cancellationToken);
+
+ Task EnableTotpAsync(
+ string sessionToken,
+ string code,
+ CancellationToken cancellationToken);
+
+ Task RegenerateRecoveryCodesAsync(
+ string sessionToken,
+ string currentPassword,
+ string code,
+ CancellationToken cancellationToken);
+
+ Task DisableTotpAsync(
+ string sessionToken,
+ string currentPassword,
+ string code,
+ CancellationToken cancellationToken);
+
+ Task LogoutAsync(string sessionToken, CancellationToken cancellationToken);
+}
diff --git a/src/Eis.Infrastructure/Authentication/AuthenticationOptions.cs b/src/Eis.Infrastructure/Authentication/AuthenticationOptions.cs
new file mode 100644
index 0000000..fa2d8bb
--- /dev/null
+++ b/src/Eis.Infrastructure/Authentication/AuthenticationOptions.cs
@@ -0,0 +1,173 @@
+using System.Globalization;
+
+namespace Eis.Infrastructure.Authentication;
+
+public sealed class AuthenticationOptions
+{
+ private AuthenticationOptions(
+ bool nativeEnabled,
+ bool production,
+ string? cacheRedisUrl,
+ string? sessionRedisUrl,
+ int sessionRedisDatabase,
+ string redisPrefix,
+ int sessionTtlSeconds,
+ int loginChallengeTtlSeconds,
+ int totpSetupTtlSeconds,
+ int redisConnectTimeoutMilliseconds,
+ string totpEncryptionMaterial)
+ {
+ NativeEnabled = nativeEnabled;
+ Production = production;
+ CacheRedisUrl = cacheRedisUrl;
+ SessionRedisUrl = sessionRedisUrl;
+ SessionRedisDatabase = sessionRedisDatabase;
+ RedisPrefix = redisPrefix;
+ SessionTtlSeconds = sessionTtlSeconds;
+ LoginChallengeTtlSeconds = loginChallengeTtlSeconds;
+ TotpSetupTtlSeconds = totpSetupTtlSeconds;
+ RedisConnectTimeoutMilliseconds = redisConnectTimeoutMilliseconds;
+ TotpEncryptionMaterial = totpEncryptionMaterial;
+ }
+
+ public bool NativeEnabled { get; }
+
+ public bool Production { get; }
+
+ public string? CacheRedisUrl { get; }
+
+ public string? SessionRedisUrl { get; }
+
+ public int SessionRedisDatabase { get; }
+
+ public string RedisPrefix { get; }
+
+ public int SessionTtlSeconds { get; }
+
+ public int LoginChallengeTtlSeconds { get; }
+
+ public int TotpSetupTtlSeconds { get; }
+
+ public int RedisConnectTimeoutMilliseconds { get; }
+
+ public string TotpEncryptionMaterial { get; }
+
+ public bool UsesRedis => !string.IsNullOrWhiteSpace(SessionRedisUrl);
+
+ public bool SharesLegacySessions => UsesRedis;
+
+ public static AuthenticationOptions FromEnvironment(bool production, bool configuredNativeEnabled = false)
+ {
+ var nativeEnabled = ParseBoolean(Environment.GetEnvironmentVariable("AUTH_NATIVE_ENABLED"), configuredNativeEnabled);
+ var cacheUrl = Clean(Environment.GetEnvironmentVariable("REDIS_URL"));
+ var explicitSessionUrl = Clean(Environment.GetEnvironmentVariable("REDIS_SESSION_URL"));
+ var sessionUrl = explicitSessionUrl ?? cacheUrl;
+ var cacheDatabase = RedisDatabase(cacheUrl);
+ var sessionDatabaseText = Clean(Environment.GetEnvironmentVariable("REDIS_SESSION_DB"));
+ var sessionDatabase = sessionDatabaseText is not null
+ ? ParseNonNegativeInteger(sessionDatabaseText, cacheDatabase == 0 ? 1 : 0, 1024)
+ : explicitSessionUrl is not null
+ ? RedisDatabase(explicitSessionUrl)
+ : cacheDatabase == 0 ? 1 : 0;
+
+ if (cacheUrl is not null && sessionUrl is not null &&
+ string.Equals(RedisEndpoint(cacheUrl), RedisEndpoint(sessionUrl), StringComparison.OrdinalIgnoreCase) &&
+ cacheDatabase == sessionDatabase)
+ {
+ throw new InvalidOperationException(
+ "Redis 认证状态必须使用与普通缓存不同的逻辑数据库;请配置 REDIS_SESSION_DB 或 REDIS_SESSION_URL");
+ }
+
+ if (nativeEnabled && production && sessionUrl is null)
+ {
+ throw new InvalidOperationException(
+ "渐进迁移期间在生产环境启用原生认证必须配置 REDIS_URL 或 REDIS_SESSION_URL,以便 Node 与 ASP.NET Core 共享会话");
+ }
+
+ var configuredTotpKey = Environment.GetEnvironmentVariable("TOTP_ENCRYPTION_KEY") ?? string.Empty;
+ if (nativeEnabled && production && configuredTotpKey.Length < 32)
+ {
+ throw new InvalidOperationException("生产环境启用原生认证前必须设置至少 32 个字符的 TOTP_ENCRYPTION_KEY");
+ }
+
+ var initialPassword = Environment.GetEnvironmentVariable("INITIAL_ADMIN_PASSWORD") ?? "local-exam-system";
+ var keyMaterial = configuredTotpKey.Length > 0 ? configuredTotpKey : $"development-only:{initialPassword}";
+ var prefix = NormalizePrefix(Environment.GetEnvironmentVariable("REDIS_SESSION_PREFIX"));
+
+ return new AuthenticationOptions(
+ nativeEnabled,
+ production,
+ cacheUrl,
+ sessionUrl,
+ sessionDatabase,
+ prefix,
+ ParsePositiveInteger(Environment.GetEnvironmentVariable("AUTH_SESSION_TTL_SECONDS"), 8 * 60 * 60, 30 * 24 * 60 * 60),
+ ParsePositiveInteger(Environment.GetEnvironmentVariable("AUTH_LOGIN_CHALLENGE_TTL_SECONDS"), 5 * 60, 60 * 60),
+ ParsePositiveInteger(Environment.GetEnvironmentVariable("AUTH_TOTP_SETUP_TTL_SECONDS"), 10 * 60, 60 * 60),
+ ParsePositiveInteger(Environment.GetEnvironmentVariable("REDIS_CONNECT_TIMEOUT_MS"), 1500, 30000),
+ keyMaterial);
+ }
+
+ internal static AuthenticationOptions CreateForTests(string totpEncryptionMaterial) => new(
+ nativeEnabled: true,
+ production: false,
+ cacheRedisUrl: null,
+ sessionRedisUrl: null,
+ sessionRedisDatabase: 1,
+ redisPrefix: "exam-information:auth",
+ sessionTtlSeconds: 8 * 60 * 60,
+ loginChallengeTtlSeconds: 5 * 60,
+ totpSetupTtlSeconds: 10 * 60,
+ redisConnectTimeoutMilliseconds: 1500,
+ totpEncryptionMaterial: totpEncryptionMaterial);
+
+ private static string NormalizePrefix(string? value)
+ {
+ var source = string.IsNullOrWhiteSpace(value) ? "exam-information:auth" : value.Trim();
+ var normalized = string.Concat(source.Select(character =>
+ char.IsAsciiLetterOrDigit(character) || character is ':' or '_' or '-' ? character : '-'));
+ return normalized.Length > 0 ? normalized : "exam-information:auth";
+ }
+
+ private static string? Clean(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ private static bool ParseBoolean(string? value, bool fallback) => value?.Trim().ToLowerInvariant() switch
+ {
+ "1" or "true" or "yes" or "on" => true,
+ "0" or "false" or "no" or "off" => false,
+ _ => fallback
+ };
+
+ private static int ParsePositiveInteger(string? value, int fallback, int maximum) =>
+ int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0
+ ? Math.Min(parsed, maximum)
+ : fallback;
+
+ private static int ParseNonNegativeInteger(string? value, int fallback, int maximum) =>
+ int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0
+ ? Math.Min(parsed, maximum)
+ : fallback;
+
+ private static int RedisDatabase(string? value)
+ {
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
+ {
+ return 0;
+ }
+
+ return int.TryParse(uri.AbsolutePath.Trim('/'), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0
+ ? parsed
+ : 0;
+ }
+
+ private static string RedisEndpoint(string value)
+ {
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
+ {
+ return string.Empty;
+ }
+
+ var port = uri.IsDefaultPort ? 6379 : uri.Port;
+ return $"{uri.Scheme}://{uri.Host}:{port}";
+ }
+}
diff --git a/src/Eis.Infrastructure/Authentication/AuthenticationRepository.cs b/src/Eis.Infrastructure/Authentication/AuthenticationRepository.cs
new file mode 100644
index 0000000..58af68e
--- /dev/null
+++ b/src/Eis.Infrastructure/Authentication/AuthenticationRepository.cs
@@ -0,0 +1,589 @@
+using System.Data.Common;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Eis.Infrastructure.Data;
+
+namespace Eis.Infrastructure.Authentication;
+
+internal sealed class AuthenticationUser
+{
+ public required string Id { get; init; }
+
+ public required string Username { get; init; }
+
+ public required string PasswordHash { get; set; }
+
+ public required string Role { get; init; }
+
+ public string? AdminLevel { get; init; }
+
+ public string? SchoolId { get; init; }
+
+ public string? ClassId { get; init; }
+
+ public bool Active { get; init; }
+
+ public bool MustChangePassword { get; set; }
+
+ public bool TotpEnabled { get; set; }
+
+ public string? TotpSecretEncrypted { get; set; }
+
+ public IReadOnlyList TotpRecoveryCodes { get; set; } = [];
+
+ public long? TotpLastUsedStep { get; set; }
+
+ public string? ArchivedAt { get; init; }
+
+ public required string DisplayName { get; init; }
+
+ public string? CandidateNumber { get; init; }
+}
+
+internal sealed record RegistrationCreationResult(int StatusCode, string? RegistrationNumber, string? ErrorMessage);
+
+internal sealed class AuthenticationRepository(IRelationalConnectionFactory connectionFactory)
+{
+ private const string UserColumns = """
+ id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
+ must_change_password, totp_enabled, totp_secret_encrypted, totp_recovery_codes,
+ totp_last_used_step, archived_at, display_name
+ """;
+
+ public Task FindUserByAccountAsync(string account, CancellationToken cancellationToken) =>
+ QueryUserAsync(
+ $"SELECT {UserColumns} FROM users WHERE LOWER(username) = @account OR LOWER(COALESCE(candidate_number, '')) = @account LIMIT 1",
+ [new("@account", account)],
+ cancellationToken);
+
+ public Task FindUserByIdAsync(string id, CancellationToken cancellationToken) =>
+ QueryUserAsync(
+ $"SELECT {UserColumns} FROM users WHERE id = @id LIMIT 1",
+ [new("@id", id)],
+ cancellationToken);
+
+ public async Task CreateSelfRegisteredCandidateAsync(
+ string name,
+ string gender,
+ string schoolId,
+ string classId,
+ string passwordHash,
+ CancellationToken cancellationToken)
+ {
+ await using var connection = await connectionFactory.OpenAsync(cancellationToken);
+ await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
+ try
+ {
+ var registrationEnabled = await ScalarAsync(
+ connection,
+ transaction,
+ "SELECT self_registration_enabled FROM schema_metadata WHERE id = 1",
+ [],
+ cancellationToken);
+ if (registrationEnabled is null || !Convert.ToBoolean(registrationEnabled, CultureInfo.InvariantCulture))
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ return new RegistrationCreationResult(
+ 403,
+ null,
+ "当前未开放自主注册,请使用学校下发的报名号和初始密码登录");
+ }
+
+ var schoolName = await ScalarAsync(
+ connection,
+ transaction,
+ "SELECT name FROM schools WHERE id = @schoolId AND active = 1 AND is_source_school = 1",
+ [new("@schoolId", schoolId)],
+ cancellationToken);
+ var className = await ScalarAsync(
+ connection,
+ transaction,
+ "SELECT name FROM school_classes WHERE id = @classId AND school_id = @schoolId AND active = 1",
+ [new("@classId", classId), new("@schoolId", schoolId)],
+ cancellationToken);
+ if (schoolName is null || className is null)
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ return new RegistrationCreationResult(400, null, "请选择有效的学校和班级");
+ }
+
+ const string ruleSql = """
+ SELECT id, separator FROM number_rules
+ WHERE active = 1 ORDER BY updated_at DESC, id LIMIT 1
+ """;
+ string? ruleId = null;
+ var separator = string.Empty;
+ await using (var ruleCommand = CreateCommand(connection, ruleSql, [], transaction))
+ await using (var reader = await ruleCommand.ExecuteReaderAsync(cancellationToken))
+ {
+ if (await reader.ReadAsync(cancellationToken))
+ {
+ ruleId = ReadString(reader, "id");
+ separator = ReadString(reader, "separator");
+ }
+ }
+
+ if (ruleId is null)
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ return new RegistrationCreationResult(409, null, "尚未配置可用的报名号生成规则");
+ }
+
+ var segments = new List();
+ const string segmentSql = """
+ SELECT type, value, width FROM number_rule_segments
+ WHERE rule_id = @ruleId ORDER BY position, id
+ """;
+ await using (var segmentCommand = CreateCommand(connection, segmentSql, [new("@ruleId", ruleId)], transaction))
+ await using (var reader = await segmentCommand.ExecuteReaderAsync(cancellationToken))
+ {
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ segments.Add(new NumberRuleSegment(
+ ReadString(reader, "type"),
+ ReadOptionalString(reader, "value") ?? string.Empty,
+ Convert.ToInt32(reader.GetValue(reader.GetOrdinal("width")), CultureInfo.InvariantCulture)));
+ }
+ }
+
+ if (segments.Count == 0)
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ return new RegistrationCreationResult(409, null, "尚未配置可用的报名号生成规则");
+ }
+
+ var year = DateTime.Now.Year.ToString(CultureInfo.InvariantCulture);
+ var schoolCodeValue = await ScalarAsync(
+ connection,
+ transaction,
+ "SELECT code FROM schools WHERE id = @schoolId",
+ [new("@schoolId", schoolId)],
+ cancellationToken);
+ var schoolCode = Convert.ToString(schoolCodeValue, CultureInfo.InvariantCulture) ?? string.Empty;
+ var prefixParts = segments
+ .Where(segment => segment.Type != "sequence")
+ .Select(segment => segment.Type switch
+ {
+ "year" => year,
+ "school_code" => schoolCode,
+ _ => string.Empty
+ })
+ .Where(value => value.Length > 0);
+ var prefix = string.Join(separator, prefixParts);
+ var existingNumbers = new List();
+ await using (var numberCommand = CreateCommand(
+ connection,
+ "SELECT candidate_number FROM users WHERE role = 'candidate' AND candidate_number IS NOT NULL",
+ [],
+ transaction))
+ await using (var reader = await numberCommand.ExecuteReaderAsync(cancellationToken))
+ {
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ existingNumbers.Add(ReadString(reader, "candidate_number"));
+ }
+ }
+
+ var sequence = existingNumbers.LongCount(number =>
+ prefix.Length == 0 || number.StartsWith(prefix, StringComparison.Ordinal)) + 1;
+ var parts = segments.Select(segment => segment.Type switch
+ {
+ "year" => LastCharacters(year, Math.Max(2, segment.Width == 0 ? 4 : segment.Width)),
+ "school_code" => schoolCode.Length > 0 ? schoolCode : "NOSCHOOL",
+ "gender" => gender == "男" ? "M" : gender == "女" ? "F" : "X",
+ "sequence" => sequence.ToString(CultureInfo.InvariantCulture).PadLeft(Math.Max(1, segment.Width == 0 ? 4 : segment.Width), '0'),
+ _ => Clean(segment.Value, 20).ToUpperInvariant()
+ });
+ var registrationNumber = string.Join(separator, parts);
+ var userId = Uid("usr");
+ var profileId = Uid("profile");
+ var now = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture);
+
+ const string userSql = """
+ INSERT INTO users (
+ id, username, candidate_number, password_hash, role, admin_level, school_id, class_id,
+ active, must_change_password, archived_at, archived_by, display_name, created_at
+ ) VALUES (
+ @id, @username, @candidateNumber, @passwordHash, 'candidate', NULL, NULL, NULL,
+ 1, 0, NULL, NULL, @displayName, @createdAt
+ )
+ """;
+ await using (var userCommand = CreateCommand(connection, userSql,
+ [
+ new("@id", userId),
+ new("@username", registrationNumber),
+ new("@candidateNumber", registrationNumber),
+ new("@passwordHash", passwordHash),
+ new("@displayName", name),
+ new("@createdAt", now)
+ ], transaction))
+ {
+ await userCommand.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ const string profileSql = """
+ INSERT INTO candidate_profiles (
+ id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
+ address, emergency_contact, emergency_phone, native_place, birth_date, ethnicity,
+ postal_code, guardian_name, guardian_phone, profile_completed, status, review_note, updated_at
+ ) VALUES (
+ @id, @userId, @name, @gender, @idNumber, '', NULL, @school, @grade, @schoolId, @classId,
+ NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 'pending', NULL, @updatedAt
+ )
+ """;
+ await using (var profileCommand = CreateCommand(connection, profileSql,
+ [
+ new("@id", profileId),
+ new("@userId", userId),
+ new("@name", name),
+ new("@gender", gender),
+ new("@idNumber", $"PENDING-{userId}"),
+ new("@school", Convert.ToString(schoolName, CultureInfo.InvariantCulture)),
+ new("@grade", Convert.ToString(className, CultureInfo.InvariantCulture)),
+ new("@schoolId", schoolId),
+ new("@classId", classId),
+ new("@updatedAt", now)
+ ], transaction))
+ {
+ await profileCommand.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ await transaction.CommitAsync(cancellationToken);
+ return new RegistrationCreationResult(201, registrationNumber, null);
+ }
+ catch
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ throw;
+ }
+ }
+
+ public async Task GetCandidateProfileAsync(string userId, CancellationToken cancellationToken)
+ {
+ const string sql = """
+ SELECT id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
+ province_code, province_name, city_code, city_name, district_code, district_name, address,
+ emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code,
+ guardian_name, guardian_phone, specialty_category, specialty_type, specialty_types,
+ specialty_certificate, policy_eligibility, profile_completed, status, review_note,
+ reviewed_at, reviewer_id, updated_at
+ FROM candidate_profiles WHERE user_id = @userId LIMIT 1
+ """;
+ await using var connection = await connectionFactory.OpenAsync(cancellationToken);
+ await using var command = CreateCommand(connection, sql, [new("@userId", userId)]);
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken))
+ {
+ return null;
+ }
+
+ return new JsonObject
+ {
+ ["id"] = ReadString(reader, "id"),
+ ["userId"] = ReadString(reader, "user_id"),
+ ["name"] = ReadString(reader, "name"),
+ ["gender"] = ReadOptionalString(reader, "gender") ?? string.Empty,
+ ["idNumber"] = ReadString(reader, "id_number"),
+ ["phone"] = ReadString(reader, "phone"),
+ ["email"] = ReadOptionalString(reader, "email") ?? string.Empty,
+ ["school"] = ReadOptionalString(reader, "school") ?? string.Empty,
+ ["grade"] = ReadOptionalString(reader, "grade") ?? string.Empty,
+ ["schoolId"] = JsonValue.Create(ReadOptionalString(reader, "school_id")),
+ ["classId"] = JsonValue.Create(ReadOptionalString(reader, "class_id")),
+ ["provinceCode"] = ReadOptionalString(reader, "province_code") ?? string.Empty,
+ ["provinceName"] = ReadOptionalString(reader, "province_name") ?? string.Empty,
+ ["cityCode"] = ReadOptionalString(reader, "city_code") ?? string.Empty,
+ ["cityName"] = ReadOptionalString(reader, "city_name") ?? string.Empty,
+ ["districtCode"] = ReadOptionalString(reader, "district_code") ?? string.Empty,
+ ["districtName"] = ReadOptionalString(reader, "district_name") ?? string.Empty,
+ ["address"] = ReadOptionalString(reader, "address") ?? string.Empty,
+ ["emergencyContact"] = ReadOptionalString(reader, "emergency_contact") ?? string.Empty,
+ ["emergencyPhone"] = ReadOptionalString(reader, "emergency_phone") ?? string.Empty,
+ ["nativePlace"] = ReadOptionalString(reader, "native_place") ?? string.Empty,
+ ["birthDate"] = ReadOptionalString(reader, "birth_date") ?? string.Empty,
+ ["ethnicity"] = ReadOptionalString(reader, "ethnicity") ?? string.Empty,
+ ["postalCode"] = ReadOptionalString(reader, "postal_code") ?? string.Empty,
+ ["guardianName"] = ReadOptionalString(reader, "guardian_name") ?? string.Empty,
+ ["guardianPhone"] = ReadOptionalString(reader, "guardian_phone") ?? string.Empty,
+ ["specialtyCategory"] = ReadOptionalString(reader, "specialty_category") ?? string.Empty,
+ ["specialtyType"] = ReadOptionalString(reader, "specialty_type") ?? string.Empty,
+ ["specialtyTypes"] = ParseArray(ReadOptionalString(reader, "specialty_types")),
+ ["specialtyCertificate"] = ReadOptionalString(reader, "specialty_certificate") ?? string.Empty,
+ ["policyEligibility"] = ReadOptionalString(reader, "policy_eligibility") ?? string.Empty,
+ ["profileCompleted"] = ReadBoolean(reader, "profile_completed"),
+ ["status"] = ReadString(reader, "status"),
+ ["reviewNote"] = ReadOptionalString(reader, "review_note") ?? string.Empty,
+ ["reviewedAt"] = JsonValue.Create(ReadOptionalString(reader, "reviewed_at")),
+ ["reviewerId"] = JsonValue.Create(ReadOptionalString(reader, "reviewer_id")),
+ ["updatedAt"] = ReadString(reader, "updated_at")
+ };
+ }
+
+ public async Task GetOrganizationNameAsync(CancellationToken cancellationToken)
+ {
+ await using var connection = await connectionFactory.OpenAsync(cancellationToken);
+ await using var command = CreateCommand(connection, "SELECT name FROM organization WHERE id = 1", []);
+ var value = await command.ExecuteScalarAsync(cancellationToken);
+ return value is null or DBNull ? "考试服务平台" : Clean(Convert.ToString(value, CultureInfo.InvariantCulture), 80);
+ }
+
+ public async Task GetAdminScopeLabelAsync(AuthenticationUser user, CancellationToken cancellationToken)
+ {
+ if ((user.AdminLevel ?? "super") == "super")
+ {
+ return "全部学校与班级";
+ }
+
+ await using var connection = await connectionFactory.OpenAsync(cancellationToken);
+ var school = await ScalarStringAsync(connection, "SELECT name FROM schools WHERE id = @id", user.SchoolId, cancellationToken)
+ ?? "未绑定学校";
+ if (user.AdminLevel == "school")
+ {
+ return school;
+ }
+
+ var schoolClass = await ScalarStringAsync(connection, "SELECT name FROM school_classes WHERE id = @id", user.ClassId, cancellationToken)
+ ?? "未绑定班级";
+ return $"{school} · {schoolClass}";
+ }
+
+ public Task UpdatePasswordAsync(AuthenticationUser user, string logId, string action, string detail, CancellationToken cancellationToken) =>
+ ExecuteUserUpdateWithAuditAsync(
+ "UPDATE users SET password_hash = @passwordHash, must_change_password = @mustChangePassword WHERE id = @id",
+ [
+ new("@passwordHash", user.PasswordHash),
+ new("@mustChangePassword", user.MustChangePassword ? 1 : 0),
+ new("@id", user.Id)
+ ],
+ user,
+ logId,
+ action,
+ detail,
+ cancellationToken);
+
+ public Task UpdateTotpSecurityAsync(
+ AuthenticationUser user,
+ string? logId,
+ string? action,
+ string? detail,
+ CancellationToken cancellationToken) => ExecuteUserUpdateWithAuditAsync(
+ """
+ UPDATE users SET totp_enabled = @enabled, totp_secret_encrypted = @secret,
+ totp_recovery_codes = @recoveryCodes, totp_last_used_step = @lastUsedStep
+ WHERE id = @id
+ """,
+ [
+ new("@enabled", user.TotpEnabled ? 1 : 0),
+ new("@secret", user.TotpSecretEncrypted),
+ new("@recoveryCodes", JsonSerializer.Serialize(user.TotpRecoveryCodes)),
+ new("@lastUsedStep", user.TotpLastUsedStep),
+ new("@id", user.Id)
+ ],
+ user,
+ logId,
+ action,
+ detail,
+ cancellationToken);
+
+ private async Task QueryUserAsync(
+ string sql,
+ IReadOnlyList parameters,
+ CancellationToken cancellationToken)
+ {
+ await using var connection = await connectionFactory.OpenAsync(cancellationToken);
+ await using var command = CreateCommand(connection, sql, parameters);
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
+ }
+
+ private async Task ExecuteUserUpdateWithAuditAsync(
+ string updateSql,
+ IReadOnlyList updateParameters,
+ AuthenticationUser user,
+ string? logId,
+ string? action,
+ string? detail,
+ CancellationToken cancellationToken)
+ {
+ await using var connection = await connectionFactory.OpenAsync(cancellationToken);
+ await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
+ try
+ {
+ await using (var update = CreateCommand(connection, updateSql, updateParameters, transaction))
+ {
+ await update.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ if (logId is not null && action is not null && detail is not null)
+ {
+ const string auditSql = """
+ INSERT INTO audit_logs (id, actor_id, action, detail, created_at)
+ VALUES (@id, @actorId, @action, @detail, @createdAt)
+ """;
+ await using var audit = CreateCommand(connection, auditSql,
+ [
+ new("@id", logId),
+ new("@actorId", user.Id),
+ new("@action", action),
+ new("@detail", detail),
+ new("@createdAt", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture))
+ ], transaction);
+ await audit.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ await transaction.CommitAsync(cancellationToken);
+ }
+ catch
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ throw;
+ }
+ }
+
+ private static AuthenticationUser ReadUser(DbDataReader reader) => new()
+ {
+ Id = ReadString(reader, "id"),
+ Username = ReadString(reader, "username"),
+ CandidateNumber = ReadOptionalString(reader, "candidate_number"),
+ PasswordHash = ReadString(reader, "password_hash"),
+ Role = ReadString(reader, "role"),
+ AdminLevel = ReadOptionalString(reader, "admin_level") ?? (ReadString(reader, "role") == "admin" ? "super" : null),
+ SchoolId = ReadOptionalString(reader, "school_id"),
+ ClassId = ReadOptionalString(reader, "class_id"),
+ Active = ReadBoolean(reader, "active"),
+ MustChangePassword = ReadBoolean(reader, "must_change_password"),
+ TotpEnabled = ReadBoolean(reader, "totp_enabled"),
+ TotpSecretEncrypted = ReadOptionalString(reader, "totp_secret_encrypted"),
+ TotpRecoveryCodes = ParseStringArray(ReadOptionalString(reader, "totp_recovery_codes")),
+ TotpLastUsedStep = ReadNullableInt64(reader, "totp_last_used_step"),
+ ArchivedAt = ReadOptionalString(reader, "archived_at"),
+ DisplayName = ReadString(reader, "display_name")
+ };
+
+ private static async Task ScalarStringAsync(
+ DbConnection connection,
+ string sql,
+ string? id,
+ CancellationToken cancellationToken)
+ {
+ if (id is null)
+ {
+ return null;
+ }
+
+ await using var command = CreateCommand(connection, sql, [new("@id", id)]);
+ var value = await command.ExecuteScalarAsync(cancellationToken);
+ return value is null or DBNull ? null : Convert.ToString(value, CultureInfo.InvariantCulture);
+ }
+
+ private static async Task