From ae472aabb03c5a0d273939f5e2f5d4880a939823 Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 22 Jul 2026 19:34:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E4=B8=BB=E6=B3=A8=E5=86=8C=E3=80=81?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E3=80=81=E9=80=80=E5=87=BA=E3=80=81=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E7=94=A8=E6=88=B7=E4=B8=8E=E5=AF=86=E7=A0=81=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=20=E5=85=BC=E5=AE=B9=E7=8E=B0=E6=9C=89=20PBKDF2=20?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E5=92=8C=20hz=5Fsession=20Cookie=20TOTP=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E3=80=81=E4=BA=8C=E6=AD=A5=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E3=80=81=E9=98=B2=E9=87=8D=E6=94=BE=E3=80=81=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E7=A0=81=E4=B8=8E=20AES-GCM=20=E5=AF=86=E9=92=A5=20=E4=B8=8E?= =?UTF-8?q?=20Node=20=E5=AE=8C=E5=85=A8=E4=B8=80=E8=87=B4=E7=9A=84=20Redis?= =?UTF-8?q?=20=E9=94=AE=E6=A0=BC=E5=BC=8F=EF=BC=8C=E5=8F=AF=E8=B7=A8?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=85=B1=E4=BA=AB=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=20=E7=94=9F=E4=BA=A7=E7=8E=AF=E5=A2=83=E5=BC=80=E5=90=AF?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E8=AE=A4=E8=AF=81=E6=97=B6=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E8=A6=81=E6=B1=82=20Redis=20=E9=BB=98=E8=AE=A4=E4=BF=9D?= =?UTF-8?q?=E6=8C=81=E5=85=BC=E5=AE=B9=E4=BB=A3=E7=90=86=EF=BC=9B=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=20AUTH=5FNATIVE=5FENABLED=3Dtrue=20=E5=8D=B3=E5=8F=AF?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + Directory.Packages.props | 2 + MIGRATION.md | 10 +- scripts/smoke-dotnet-migration.ps1 | 139 ++++- .../Authentication/IAuthenticationService.cs | 57 ++ .../Authentication/AuthenticationOptions.cs | 173 +++++ .../AuthenticationRepository.cs | 589 ++++++++++++++++++ .../Authentication/AuthenticationService.cs | 552 ++++++++++++++++ .../IAuthenticationStateStore.cs | 38 ++ .../MemoryAuthenticationStateStore.cs | 155 +++++ .../PasswordCompatibilityService.cs | 44 ++ .../RedisAuthenticationStateStore.cs | 222 +++++++ .../TotpCompatibilityService.cs | 188 ++++++ .../Data/RelationalConnectionFactory.cs | 2 +- src/Eis.Infrastructure/DependencyInjection.cs | 24 +- .../Eis.Infrastructure.csproj | 2 + .../Migration/MigrationFeatureCatalog.cs | 4 +- .../PublicQueryService.Announcements.cs | 26 +- .../NativeAuthenticationEndpoints.cs | 199 ++++++ src/Eis.Web/Program.cs | 18 +- src/Eis.Web/appsettings.json | 3 + .../AuthenticationStateStoreTests.cs | 48 ++ .../PasswordCompatibilityServiceTests.cs | 29 + .../TotpCompatibilityServiceTests.cs | 47 ++ tests/helpers/current-totp-code.mjs | 5 + tests/helpers/enable-self-registration.mjs | 8 + 26 files changed, 2569 insertions(+), 21 deletions(-) create mode 100644 src/Eis.Application/Authentication/IAuthenticationService.cs create mode 100644 src/Eis.Infrastructure/Authentication/AuthenticationOptions.cs create mode 100644 src/Eis.Infrastructure/Authentication/AuthenticationRepository.cs create mode 100644 src/Eis.Infrastructure/Authentication/AuthenticationService.cs create mode 100644 src/Eis.Infrastructure/Authentication/IAuthenticationStateStore.cs create mode 100644 src/Eis.Infrastructure/Authentication/MemoryAuthenticationStateStore.cs create mode 100644 src/Eis.Infrastructure/Authentication/PasswordCompatibilityService.cs create mode 100644 src/Eis.Infrastructure/Authentication/RedisAuthenticationStateStore.cs create mode 100644 src/Eis.Infrastructure/Authentication/TotpCompatibilityService.cs create mode 100644 src/Eis.Web/Authentication/NativeAuthenticationEndpoints.cs create mode 100644 tests/Eis.Infrastructure.Tests/Authentication/AuthenticationStateStoreTests.cs create mode 100644 tests/Eis.Infrastructure.Tests/Authentication/PasswordCompatibilityServiceTests.cs create mode 100644 tests/Eis.Infrastructure.Tests/Authentication/TotpCompatibilityServiceTests.cs create mode 100644 tests/helpers/current-totp-code.mjs create mode 100644 tests/helpers/enable-self-registration.mjs 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 ScalarAsync( + DbConnection connection, + DbTransaction transaction, + string sql, + IReadOnlyList parameters, + CancellationToken cancellationToken) + { + await using var command = CreateCommand(connection, sql, parameters, transaction); + var value = await command.ExecuteScalarAsync(cancellationToken); + return value is DBNull ? null : value; + } + + private static DbCommand CreateCommand( + DbConnection connection, + string sql, + IReadOnlyList parameters, + DbTransaction? transaction = null) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + command.Transaction = transaction; + foreach (var item in parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = item.Name; + parameter.Value = item.Value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + return command; + } + + private static string ReadString(DbDataReader reader, string name) => + Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty; + + private static string? ReadOptionalString(DbDataReader reader, string name) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture); + } + + private static bool ReadBoolean(DbDataReader reader, string name) + { + var value = reader.GetValue(reader.GetOrdinal(name)); + return value switch + { + bool boolean => boolean, + byte number => number != 0, + short number => number != 0, + int number => number != 0, + long number => number != 0, + _ => Convert.ToBoolean(value, CultureInfo.InvariantCulture) + }; + } + + private static long? ReadNullableInt64(DbDataReader reader, string name) + { + var ordinal = reader.GetOrdinal(name); + return reader.IsDBNull(ordinal) ? null : Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture); + } + + private static IReadOnlyList ParseStringArray(string? value) + { + try + { + return JsonSerializer.Deserialize(value ?? "[]") ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static JsonArray ParseArray(string? value) + { + try + { + return JsonNode.Parse(value ?? "[]")?.AsArray() ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static string Clean(string? value, int maximum) => (value ?? string.Empty).Trim()[..Math.Min((value ?? string.Empty).Trim().Length, maximum)]; + + private static string LastCharacters(string value, int count) => value[Math.Max(0, value.Length - count)..]; + + private static string Uid(string prefix) => + $"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(System.Security.Cryptography.RandomNumberGenerator.GetBytes(4))}"; + + private static string ToBase36(long value) + { + const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; + Span buffer = stackalloc char[16]; + var position = buffer.Length; + do + { + buffer[--position] = alphabet[(int)(value % 36)]; + value /= 36; + } + while (value > 0); + return new string(buffer[position..]); + } + + private sealed record SqlParameterValue(string Name, object? Value); + + private sealed record NumberRuleSegment(string Type, string Value, int Width); +} diff --git a/src/Eis.Infrastructure/Authentication/AuthenticationService.cs b/src/Eis.Infrastructure/Authentication/AuthenticationService.cs new file mode 100644 index 0000000..814c830 --- /dev/null +++ b/src/Eis.Infrastructure/Authentication/AuthenticationService.cs @@ -0,0 +1,552 @@ +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using Eis.Application.Authentication; +using QRCoder; + +namespace Eis.Infrastructure.Authentication; + +internal sealed class AuthenticationService( + AuthenticationRepository repository, + IAuthenticationStateStore state, + PasswordCompatibilityService passwords, + TotpCompatibilityService totp, + AuthenticationOptions options) : IAuthenticationService +{ + private static readonly IReadOnlyDictionary PermissionsByLevel = + new Dictionary(StringComparer.Ordinal) + { + ["super"] = ["*"], + ["school"] = + [ + "dashboard.read", "candidates.read", "candidates.write", "candidates.review", + "registrations.read", "registrations.review", "payments.read", "payments.write", + "results.read", "centers.read", "centers.write", "workflows.inbox" + ], + ["class"] = + [ + "dashboard.read", "candidates.read", "candidates.review", "registrations.read", + "registrations.review", "payments.read", "payments.write", "results.read", "workflows.inbox" + ] + }; + + public async Task RegisterAsync( + string name, + string gender, + string password, + string schoolId, + string classId, + CancellationToken cancellationToken) + { + var normalizedName = Clean(name, 30); + var normalizedGender = Clean(gender, 10); + if (normalizedName.Length == 0 || normalizedGender is not ("男" or "女")) + { + return Error(400, "请填写姓名并选择性别"); + } + + if (password.Length < 8) + { + return Error(400, "密码至少需要 8 位"); + } + + var result = await repository.CreateSelfRegisteredCandidateAsync( + normalizedName, + normalizedGender, + Clean(schoolId, 64), + Clean(classId, 64), + passwords.Hash(password), + cancellationToken); + if (result.RegistrationNumber is null) + { + return Error(result.StatusCode, result.ErrorMessage ?? "自主注册失败"); + } + + return new AuthenticationEndpointResult( + 201, + new JsonObject + { + ["ok"] = true, + ["registrationNumber"] = result.RegistrationNumber, + ["message"] = "报名号已生成,请使用该号码登录并补全个人信息" + }); + } + + public async Task GetCurrentUserAsync( + string sessionToken, + CancellationToken cancellationToken) + { + var user = await CurrentUserAsync(sessionToken, cancellationToken); + if (user is null) + { + return Success(new JsonObject { ["ok"] = true, ["user"] = null }); + } + + var response = new JsonObject + { + ["ok"] = true, + ["user"] = SafeUser(user), + ["profile"] = user.Role == "candidate" + ? await repository.GetCandidateProfileAsync(user.Id, cancellationToken) + : null + }; + if (user.Role == "admin") + { + var level = user.AdminLevel ?? "super"; + response["permissions"] = new JsonArray( + (PermissionsByLevel.GetValueOrDefault(level) ?? []) + .Select(value => JsonValue.Create(value)) + .ToArray()); + response["scopeLabel"] = await repository.GetAdminScopeLabelAsync(user, cancellationToken); + } + + return Success(response); + } + + public async Task LoginAsync( + string username, + string password, + CancellationToken cancellationToken) + { + var account = Clean(username, 120).ToLowerInvariant(); + var user = await repository.FindUserByAccountAsync(account, cancellationToken); + if (!CanLogin(user) || !passwords.Verify(password, user!.PasswordHash)) + { + return Error(401, "账号或密码不正确"); + } + + if (user.TotpEnabled) + { + var challenge = Base64Url(RandomNumberGenerator.GetBytes(32)); + await state.CreateLoginChallengeAsync(challenge, user.Id); + return Success(new JsonObject + { + ["ok"] = true, + ["requiresTotp"] = true, + ["challenge"] = challenge + }); + } + + return await IssueSessionAsync(user); + } + + public async Task CompleteTotpLoginAsync( + string challenge, + string code, + CancellationToken cancellationToken) + { + var challengeState = await state.GetLoginChallengeAsync(challenge); + if (challengeState is null || challengeState.Attempts >= 5) + { + await state.DeleteLoginChallengeAsync(challenge); + return Error(401, "验证请求已过期,请重新输入账号和密码"); + } + + var user = await repository.FindUserByIdAsync(challengeState.UserId, cancellationToken); + if (!CanLogin(user) || !user!.TotpEnabled) + { + await state.DeleteLoginChallengeAsync(challenge); + return Error(401, "验证请求已失效,请重新登录"); + } + + var verified = VerifySecondFactor(user, code); + if (verified is null) + { + var failure = await state.RecordLoginChallengeFailureAsync(challenge, 5); + var message = failure is null + ? "验证请求已过期,请重新输入账号和密码" + : failure.Attempts >= 5 + ? "验证失败次数过多,请重新登录" + : "验证码或恢复码不正确"; + return Error(401, message); + } + + string? logId = null; + string? action = null; + string? detail = null; + if (verified.Type == SecondFactorType.Totp) + { + user.TotpLastUsedStep = verified.Step; + } + else + { + user.TotpRecoveryCodes = verified.RecoveryCodes!; + logId = Uid("log"); + action = "使用 TOTP 恢复码登录"; + detail = user.Username; + } + + await repository.UpdateTotpSecurityAsync(user, logId, action, detail, cancellationToken); + await state.DeleteLoginChallengeAsync(challenge); + var issued = await IssueSessionAsync(user); + issued.Body["usedRecoveryCode"] = verified.Type == SecondFactorType.Recovery; + return issued; + } + + public async Task ChangePasswordAsync( + string sessionToken, + string currentPassword, + string newPassword, + CancellationToken cancellationToken) + { + var user = await CurrentUserAsync(sessionToken, cancellationToken); + if (user is null) + { + return Error(401, "请先登录"); + } + + if (!passwords.Verify(currentPassword, user.PasswordHash)) + { + return Error(400, "当前密码不正确"); + } + + if (newPassword.Length < 8) + { + return Error(400, "新密码至少需要 8 位"); + } + + if (newPassword == currentPassword) + { + return Error(400, "新密码不能与当前密码相同"); + } + + user.PasswordHash = passwords.Hash(newPassword); + user.MustChangePassword = false; + var detail = user.Role == "candidate" ? $"报名号 {user.CandidateNumber}" : user.Username; + await repository.UpdatePasswordAsync(user, Uid("log"), "修改登录密码", detail, cancellationToken); + return Success(new JsonObject { ["ok"] = true, ["user"] = SafeUser(user) }); + } + + public async Task GetTotpStatusAsync( + string sessionToken, + CancellationToken cancellationToken) + { + var user = await CurrentUserAsync(sessionToken, cancellationToken); + return user is null + ? Error(401, "请先登录") + : Success(new JsonObject + { + ["ok"] = true, + ["enabled"] = user.TotpEnabled, + ["recoveryCodesRemaining"] = user.TotpEnabled ? user.TotpRecoveryCodes.Count : 0 + }); + } + + public async Task BeginTotpSetupAsync( + string sessionToken, + string currentPassword, + CancellationToken cancellationToken) + { + var user = await CurrentUserAsync(sessionToken, cancellationToken); + if (user is null) + { + return Error(401, "请先登录"); + } + + if (user.MustChangePassword) + { + return Error(400, "请先修改初始密码,再启用二次验证"); + } + + if (user.TotpEnabled) + { + return Error(409, "当前账号已经启用 TOTP 二次验证"); + } + + if (!passwords.Verify(currentPassword, user.PasswordHash)) + { + return Error(400, "当前密码不正确"); + } + + var issuer = Clean(await repository.GetOrganizationNameAsync(cancellationToken), 80); + var secret = totp.CreateSecret(); + var uri = totp.BuildOtpAuthUri(secret, user.CandidateNumber ?? user.Username, issuer); + await state.CreateTotpSetupAsync(sessionToken, user.Id, secret); + return Success(new JsonObject + { + ["ok"] = true, + ["secret"] = secret, + ["uri"] = uri, + ["qrCode"] = CreateQrCodeDataUrl(uri), + ["expiresIn"] = 600 + }); + } + + public async Task EnableTotpAsync( + string sessionToken, + string code, + CancellationToken cancellationToken) + { + var user = await CurrentUserAsync(sessionToken, cancellationToken); + if (user is null) + { + return Error(401, "请先登录"); + } + + var setup = await state.GetTotpSetupAsync(sessionToken); + if (setup is null || setup.UserId != user.Id) + { + await state.DeleteTotpSetupAsync(sessionToken); + return Error(400, "绑定信息已过期,请重新开始"); + } + + var step = totp.Verify(code, setup.Secret); + if (step is null) + { + return Error(400, "动态验证码不正确,请确认设备时间准确后重试"); + } + + var recoveryCodes = totp.CreateRecoveryCodes(); + user.TotpEnabled = true; + user.TotpSecretEncrypted = totp.EncryptSecret(setup.Secret); + user.TotpRecoveryCodes = recoveryCodes.Select(totp.HashRecoveryCode).ToArray(); + user.TotpLastUsedStep = step; + await repository.UpdateTotpSecurityAsync( + user, + Uid("log"), + "启用 TOTP 二次验证", + user.Username, + cancellationToken); + await state.DeleteTotpSetupAsync(sessionToken); + return Success(new JsonObject + { + ["ok"] = true, + ["recoveryCodes"] = new JsonArray(recoveryCodes.Select(value => JsonValue.Create(value)).ToArray()), + ["user"] = SafeUser(user) + }); + } + + public async Task RegenerateRecoveryCodesAsync( + string sessionToken, + string currentPassword, + string code, + CancellationToken cancellationToken) + { + var user = await CurrentUserAsync(sessionToken, cancellationToken); + if (user is null) + { + return Error(401, "请先登录"); + } + + if (!user.TotpEnabled) + { + return Error(400, "当前账号尚未启用 TOTP 二次验证"); + } + + if (!passwords.Verify(currentPassword, user.PasswordHash)) + { + return Error(400, "当前密码不正确"); + } + + var verified = VerifySecondFactor(user, code); + if (verified is null) + { + return Error(400, "动态验证码或恢复码不正确"); + } + + var recoveryCodes = totp.CreateRecoveryCodes(); + user.TotpRecoveryCodes = recoveryCodes.Select(totp.HashRecoveryCode).ToArray(); + if (verified.Type == SecondFactorType.Totp) + { + user.TotpLastUsedStep = verified.Step; + } + + await repository.UpdateTotpSecurityAsync( + user, + Uid("log"), + "重新生成 TOTP 恢复码", + user.Username, + cancellationToken); + return Success(new JsonObject + { + ["ok"] = true, + ["recoveryCodes"] = new JsonArray(recoveryCodes.Select(value => JsonValue.Create(value)).ToArray()) + }); + } + + public async Task DisableTotpAsync( + string sessionToken, + string currentPassword, + string code, + CancellationToken cancellationToken) + { + var user = await CurrentUserAsync(sessionToken, cancellationToken); + if (user is null) + { + return Error(401, "请先登录"); + } + + if (!user.TotpEnabled) + { + return Error(400, "当前账号尚未启用 TOTP 二次验证"); + } + + if (!passwords.Verify(currentPassword, user.PasswordHash)) + { + return Error(400, "当前密码不正确"); + } + + if (VerifySecondFactor(user, code) is null) + { + return Error(400, "动态验证码或恢复码不正确"); + } + + user.TotpEnabled = false; + user.TotpSecretEncrypted = null; + user.TotpRecoveryCodes = []; + user.TotpLastUsedStep = null; + await repository.UpdateTotpSecurityAsync( + user, + Uid("log"), + "关闭 TOTP 二次验证", + user.Username, + cancellationToken); + await state.DeleteTotpSetupAsync(sessionToken); + return Success(new JsonObject { ["ok"] = true, ["user"] = SafeUser(user) }); + } + + public async Task LogoutAsync( + string sessionToken, + CancellationToken cancellationToken) + { + _ = cancellationToken; + if (sessionToken.Length > 0) + { + await state.DeleteSessionAsync(sessionToken); + } + + return new AuthenticationEndpointResult( + 200, + new JsonObject { ["ok"] = true }, + "hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); + } + + private async Task CurrentUserAsync(string sessionToken, CancellationToken cancellationToken) + { + if (sessionToken.Length == 0) + { + return null; + } + + var userId = await state.GetSessionUserIdAsync(sessionToken); + if (userId is null) + { + return null; + } + + var user = await repository.FindUserByIdAsync(userId, cancellationToken); + return CanLogin(user) ? user : null; + } + + private async Task IssueSessionAsync(AuthenticationUser user) + { + var token = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)); + await state.CreateSessionAsync(token, user.Id); + var secure = options.Production ? "; Secure" : string.Empty; + var cookie = $"hz_session={token}; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age={state.SessionTtlSeconds}"; + return new AuthenticationEndpointResult( + 200, + new JsonObject { ["ok"] = true, ["user"] = SafeUser(user) }, + cookie); + } + + private SecondFactorResult? VerifySecondFactor(AuthenticationUser user, string code) + { + if (!user.TotpEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted)) + { + return null; + } + + try + { + var normalized = code.Trim(); + if (normalized.Length == 6 && normalized.All(char.IsAsciiDigit)) + { + var step = totp.Verify(normalized, totp.DecryptSecret(user.TotpSecretEncrypted), user.TotpLastUsedStep); + return step is null ? null : new SecondFactorResult(SecondFactorType.Totp, step, null); + } + + var recoveryCodes = totp.ConsumeRecoveryCode(normalized, user.TotpRecoveryCodes); + return recoveryCodes is null + ? null + : new SecondFactorResult(SecondFactorType.Recovery, null, recoveryCodes); + } + catch (CryptographicException) + { + return null; + } + catch (FormatException) + { + return null; + } + } + + private static JsonObject SafeUser(AuthenticationUser user) => new() + { + ["id"] = user.Id, + ["username"] = user.Username, + ["role"] = user.Role, + ["adminLevel"] = user.Role == "admin" ? user.AdminLevel ?? "super" : null, + ["schoolId"] = JsonValue.Create(user.SchoolId), + ["classId"] = JsonValue.Create(user.ClassId), + ["displayName"] = user.DisplayName, + ["candidateNumber"] = JsonValue.Create(user.CandidateNumber), + ["mustChangePassword"] = user.MustChangePassword, + ["totpEnabled"] = user.TotpEnabled, + ["archived"] = user.ArchivedAt is not null + }; + + private static bool CanLogin(AuthenticationUser? user) => user is { Active: true, ArchivedAt: null }; + + private static AuthenticationEndpointResult Success(JsonObject body) => new(200, body); + + private static AuthenticationEndpointResult Error(int statusCode, string message) => new( + statusCode, + new JsonObject { ["ok"] = false, ["message"] = message }); + + private static string Clean(string? value, int maximum) + { + var normalized = (value ?? string.Empty).Trim(); + return normalized[..Math.Min(normalized.Length, maximum)]; + } + + private static string CreateQrCodeDataUrl(string uri) + { + using var generator = new QRCodeGenerator(); + using var data = generator.CreateQrCode(uri, QRCodeGenerator.ECCLevel.M); + using var qrCode = new PngByteQRCode(data); + return $"data:image/png;base64,{Convert.ToBase64String(qrCode.GetGraphic(10))}"; + } + + private static string Base64Url(byte[] value) => Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private static string Uid(string prefix) => + $"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}"; + + private static string ToBase36(long value) + { + const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; + Span buffer = stackalloc char[16]; + var position = buffer.Length; + do + { + buffer[--position] = alphabet[(int)(value % 36)]; + value /= 36; + } + while (value > 0); + return new string(buffer[position..]); + } + + private enum SecondFactorType + { + Totp, + Recovery + } + + private sealed record SecondFactorResult( + SecondFactorType Type, + long? Step, + IReadOnlyList? RecoveryCodes); +} diff --git a/src/Eis.Infrastructure/Authentication/IAuthenticationStateStore.cs b/src/Eis.Infrastructure/Authentication/IAuthenticationStateStore.cs new file mode 100644 index 0000000..2228568 --- /dev/null +++ b/src/Eis.Infrastructure/Authentication/IAuthenticationStateStore.cs @@ -0,0 +1,38 @@ +namespace Eis.Infrastructure.Authentication; + +internal sealed record LoginChallenge(string UserId, int Attempts); + +internal sealed record TotpSetup(string UserId, string Secret); + +internal interface IAuthenticationStateStore +{ + string Backend { get; } + + int? Database { get; } + + int SessionTtlSeconds { get; } + + Task CreateSessionAsync(string token, string userId); + + Task GetSessionUserIdAsync(string token); + + Task DeleteSessionAsync(string token); + + Task DeleteUserSessionsAsync(string userId); + + Task DeleteUsersSessionsAsync(IEnumerable userIds); + + Task CreateLoginChallengeAsync(string key, string userId); + + Task GetLoginChallengeAsync(string key); + + Task RecordLoginChallengeFailureAsync(string key, int maximumAttempts); + + Task DeleteLoginChallengeAsync(string key); + + Task CreateTotpSetupAsync(string token, string userId, string secret); + + Task GetTotpSetupAsync(string token); + + Task DeleteTotpSetupAsync(string token); +} diff --git a/src/Eis.Infrastructure/Authentication/MemoryAuthenticationStateStore.cs b/src/Eis.Infrastructure/Authentication/MemoryAuthenticationStateStore.cs new file mode 100644 index 0000000..817b6ba --- /dev/null +++ b/src/Eis.Infrastructure/Authentication/MemoryAuthenticationStateStore.cs @@ -0,0 +1,155 @@ +using System.Collections.Concurrent; + +namespace Eis.Infrastructure.Authentication; + +internal sealed class MemoryAuthenticationStateStore(AuthenticationOptions options) : IAuthenticationStateStore +{ + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _challenges = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _setups = new(StringComparer.Ordinal); + + public string Backend => "memory"; + + public int? Database => null; + + public int SessionTtlSeconds => options.SessionTtlSeconds; + + public Task CreateSessionAsync(string token, string userId) + { + _sessions[token] = new ExpiringSession(userId, ExpiresIn(options.SessionTtlSeconds)); + return Task.CompletedTask; + } + + public Task GetSessionUserIdAsync(string token) + { + if (!_sessions.TryGetValue(token, out var item) || item.ExpiresAt <= DateTimeOffset.UtcNow) + { + _sessions.TryRemove(token, out _); + return Task.FromResult(null); + } + + return Task.FromResult(item.UserId); + } + + public Task DeleteSessionAsync(string token) + { + _sessions.TryRemove(token, out _); + return Task.CompletedTask; + } + + public Task DeleteUserSessionsAsync(string userId) + { + var deleted = 0; + foreach (var item in _sessions) + { + if (item.Value.UserId == userId && _sessions.TryRemove(item.Key, out _)) + { + deleted++; + } + } + + return Task.FromResult(deleted); + } + + public async Task DeleteUsersSessionsAsync(IEnumerable userIds) + { + var deleted = 0; + foreach (var userId in userIds.ToHashSet(StringComparer.Ordinal)) + { + deleted += await DeleteUserSessionsAsync(userId); + } + + return deleted; + } + + public Task CreateLoginChallengeAsync(string key, string userId) + { + _challenges[key] = new ExpiringChallenge(userId, 0, ExpiresIn(options.LoginChallengeTtlSeconds)); + return Task.CompletedTask; + } + + public Task GetLoginChallengeAsync(string key) + { + if (!TryGetLive(_challenges, key, out var item)) + { + return Task.FromResult(null); + } + + return Task.FromResult(new LoginChallenge(item.UserId, item.Attempts)); + } + + public Task RecordLoginChallengeFailureAsync(string key, int maximumAttempts) + { + while (TryGetLive(_challenges, key, out var item)) + { + var updated = item with { Attempts = item.Attempts + 1 }; + if (!_challenges.TryUpdate(key, updated, item)) + { + continue; + } + + if (updated.Attempts >= maximumAttempts) + { + _challenges.TryRemove(key, out _); + } + + return Task.FromResult(new LoginChallenge(updated.UserId, updated.Attempts)); + } + + return Task.FromResult(null); + } + + public Task DeleteLoginChallengeAsync(string key) + { + _challenges.TryRemove(key, out _); + return Task.CompletedTask; + } + + public Task CreateTotpSetupAsync(string token, string userId, string secret) + { + _setups[token] = new ExpiringSetup(userId, secret, ExpiresIn(options.TotpSetupTtlSeconds)); + return Task.CompletedTask; + } + + public Task GetTotpSetupAsync(string token) + { + if (!TryGetLive(_setups, token, out var item)) + { + return Task.FromResult(null); + } + + return Task.FromResult(new TotpSetup(item.UserId, item.Secret)); + } + + public Task DeleteTotpSetupAsync(string token) + { + _setups.TryRemove(token, out _); + return Task.CompletedTask; + } + + private static DateTimeOffset ExpiresIn(int seconds) => DateTimeOffset.UtcNow.AddSeconds(seconds); + + private static bool TryGetLive(ConcurrentDictionary items, string key, out T item) + where T : IExpiring + { + if (items.TryGetValue(key, out item!) && item.ExpiresAt > DateTimeOffset.UtcNow) + { + return true; + } + + items.TryRemove(key, out _); + item = default!; + return false; + } + + private interface IExpiring + { + DateTimeOffset ExpiresAt { get; } + } + + private sealed record ExpiringSession(string UserId, DateTimeOffset ExpiresAt) : IExpiring; + + private sealed record ExpiringChallenge(string UserId, int Attempts, DateTimeOffset ExpiresAt) : IExpiring; + + private sealed record ExpiringSetup(string UserId, string Secret, DateTimeOffset ExpiresAt) : IExpiring; +} diff --git a/src/Eis.Infrastructure/Authentication/PasswordCompatibilityService.cs b/src/Eis.Infrastructure/Authentication/PasswordCompatibilityService.cs new file mode 100644 index 0000000..d3d374f --- /dev/null +++ b/src/Eis.Infrastructure/Authentication/PasswordCompatibilityService.cs @@ -0,0 +1,44 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Eis.Infrastructure.Authentication; + +internal sealed class PasswordCompatibilityService +{ + private const int Iterations = 120_000; + private const int HashLength = 32; + + public string Hash(string password) + { + var salt = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)); + var hash = Derive(password, salt); + return $"{salt}:{Convert.ToHexStringLower(hash)}"; + } + + public bool Verify(string password, string? stored) + { + var parts = (stored ?? string.Empty).Split(':', 2); + if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length != HashLength * 2) + { + return false; + } + + try + { + var expected = Convert.FromHexString(parts[1]); + var actual = Derive(password, parts[0]); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + catch (FormatException) + { + return false; + } + } + + private static byte[] Derive(string password, string salt) => Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), + Encoding.UTF8.GetBytes(salt), + Iterations, + HashAlgorithmName.SHA256, + HashLength); +} diff --git a/src/Eis.Infrastructure/Authentication/RedisAuthenticationStateStore.cs b/src/Eis.Infrastructure/Authentication/RedisAuthenticationStateStore.cs new file mode 100644 index 0000000..994bfce --- /dev/null +++ b/src/Eis.Infrastructure/Authentication/RedisAuthenticationStateStore.cs @@ -0,0 +1,222 @@ +using StackExchange.Redis; + +namespace Eis.Infrastructure.Authentication; + +internal sealed class RedisAuthenticationStateStore : IAuthenticationStateStore, IDisposable +{ + private const string RecordFailureScript = """ + if redis.call('EXISTS', KEYS[1]) == 0 then + return -1 + end + local attempts = redis.call('HINCRBY', KEYS[1], 'attempts', 1) + if attempts >= tonumber(ARGV[1]) then + redis.call('DEL', KEYS[1]) + end + return attempts + """; + + private readonly AuthenticationOptions _options; + private readonly ConnectionMultiplexer _connection; + private readonly IDatabase _database; + + public RedisAuthenticationStateStore(AuthenticationOptions options) + { + _options = options; + var configuration = BuildConfiguration(options.SessionRedisUrl!); + configuration.DefaultDatabase = options.SessionRedisDatabase; + configuration.ConnectTimeout = options.RedisConnectTimeoutMilliseconds; + configuration.AbortOnConnectFail = true; + try + { + _connection = ConnectionMultiplexer.Connect(configuration); + _database = _connection.GetDatabase(options.SessionRedisDatabase); + } + catch (RedisException error) + { + throw new InvalidOperationException($"Redis 认证状态存储连接失败:{error.Message}", error); + } + } + + public string Backend => "redis"; + + public int? Database => _options.SessionRedisDatabase; + + public int SessionTtlSeconds => _options.SessionTtlSeconds; + + public async Task CreateSessionAsync(string token, string userId) + { + var transaction = _database.CreateTransaction(); + _ = transaction.StringSetAsync(SessionKey(token), userId, TimeSpan.FromSeconds(_options.SessionTtlSeconds)); + _ = transaction.SetAddAsync(UserSessionsKey(userId), token); + _ = transaction.KeyExpireAsync(UserSessionsKey(userId), TimeSpan.FromSeconds(_options.SessionTtlSeconds)); + if (!await transaction.ExecuteAsync()) + { + throw new InvalidOperationException("Redis 会话写入事务未能执行"); + } + } + + public async Task GetSessionUserIdAsync(string token) + { + var value = await _database.StringGetAsync(SessionKey(token)); + return value.HasValue ? value.ToString() : null; + } + + public async Task DeleteSessionAsync(string token) + { + var key = SessionKey(token); + var userId = await _database.StringGetAsync(key); + var transaction = _database.CreateTransaction(); + _ = transaction.KeyDeleteAsync(key); + if (userId.HasValue) + { + _ = transaction.SetRemoveAsync(UserSessionsKey(userId.ToString()), token); + } + + await transaction.ExecuteAsync(); + } + + public async Task DeleteUserSessionsAsync(string userId) + { + var indexKey = UserSessionsKey(userId); + var tokens = await _database.SetMembersAsync(indexKey); + if (tokens.Length == 0) + { + await _database.KeyDeleteAsync(indexKey); + return 0; + } + + var transaction = _database.CreateTransaction(); + foreach (var token in tokens) + { + _ = transaction.KeyDeleteAsync(SessionKey(token.ToString())); + } + _ = transaction.KeyDeleteAsync(indexKey); + if (!await transaction.ExecuteAsync()) + { + throw new InvalidOperationException("Redis 用户会话失效事务未能执行"); + } + + return tokens.Length; + } + + public async Task DeleteUsersSessionsAsync(IEnumerable userIds) + { + var tasks = userIds.ToHashSet(StringComparer.Ordinal).Select(DeleteUserSessionsAsync); + var counts = await Task.WhenAll(tasks); + return counts.Sum(); + } + + public async Task CreateLoginChallengeAsync(string key, string userId) + { + var redisKey = LoginChallengeKey(key); + var transaction = _database.CreateTransaction(); + _ = transaction.HashSetAsync(redisKey, + [ + new HashEntry("userId", userId), + new HashEntry("attempts", "0") + ]); + _ = transaction.KeyExpireAsync(redisKey, TimeSpan.FromSeconds(_options.LoginChallengeTtlSeconds)); + if (!await transaction.ExecuteAsync()) + { + throw new InvalidOperationException("Redis 登录挑战写入事务未能执行"); + } + } + + public async Task GetLoginChallengeAsync(string key) + { + var values = await _database.HashGetAsync(LoginChallengeKey(key), ["userId", "attempts"]); + if (!values[0].HasValue) + { + return null; + } + + return new LoginChallenge(values[0].ToString(), ParseAttempts(values[1])); + } + + public async Task RecordLoginChallengeFailureAsync(string key, int maximumAttempts) + { + var challenge = await GetLoginChallengeAsync(key); + if (challenge is null) + { + return null; + } + + var result = await _database.ScriptEvaluateAsync( + RecordFailureScript, + [LoginChallengeKey(key)], + [maximumAttempts]); + var attempts = (int)(long)result; + return attempts < 0 ? null : new LoginChallenge(challenge.UserId, attempts); + } + + public Task DeleteLoginChallengeAsync(string key) => _database.KeyDeleteAsync(LoginChallengeKey(key)); + + public async Task CreateTotpSetupAsync(string token, string userId, string secret) + { + var key = TotpSetupKey(token); + var transaction = _database.CreateTransaction(); + _ = transaction.HashSetAsync(key, + [ + new HashEntry("userId", userId), + new HashEntry("secret", secret) + ]); + _ = transaction.KeyExpireAsync(key, TimeSpan.FromSeconds(_options.TotpSetupTtlSeconds)); + if (!await transaction.ExecuteAsync()) + { + throw new InvalidOperationException("Redis TOTP 绑定状态写入事务未能执行"); + } + } + + public async Task GetTotpSetupAsync(string token) + { + var values = await _database.HashGetAsync(TotpSetupKey(token), ["userId", "secret"]); + return values[0].HasValue && values[1].HasValue + ? new TotpSetup(values[0].ToString(), values[1].ToString()) + : null; + } + + public Task DeleteTotpSetupAsync(string token) => _database.KeyDeleteAsync(TotpSetupKey(token)); + + public void Dispose() => _connection.Dispose(); + + private RedisKey SessionKey(string token) => $"{_options.RedisPrefix}:session:{token}"; + + private RedisKey UserSessionsKey(string userId) => $"{_options.RedisPrefix}:user-sessions:{userId}"; + + private RedisKey LoginChallengeKey(string key) => $"{_options.RedisPrefix}:login-challenge:{key}"; + + private RedisKey TotpSetupKey(string token) => $"{_options.RedisPrefix}:totp-setup:{token}"; + + private static int ParseAttempts(RedisValue value) => int.TryParse(value.ToString(), out var attempts) ? attempts : 0; + + internal static ConfigurationOptions BuildConfiguration(string value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + uri.Scheme is not ("redis" or "rediss")) + { + throw new InvalidOperationException("REDIS_SESSION_URL/REDIS_URL 必须是有效的 redis:// 或 rediss:// 地址"); + } + + var configuration = new ConfigurationOptions + { + Ssl = uri.Scheme == "rediss", + SslHost = uri.Scheme == "rediss" ? uri.Host : null + }; + configuration.EndPoints.Add(uri.Host, uri.IsDefaultPort ? 6379 : uri.Port); + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var credentials = uri.UserInfo.Split(':', 2); + if (credentials.Length == 2) + { + configuration.User = Uri.UnescapeDataString(credentials[0]); + configuration.Password = Uri.UnescapeDataString(credentials[1]); + } + else + { + configuration.Password = Uri.UnescapeDataString(credentials[0]); + } + } + + return configuration; + } +} diff --git a/src/Eis.Infrastructure/Authentication/TotpCompatibilityService.cs b/src/Eis.Infrastructure/Authentication/TotpCompatibilityService.cs new file mode 100644 index 0000000..4748d3a --- /dev/null +++ b/src/Eis.Infrastructure/Authentication/TotpCompatibilityService.cs @@ -0,0 +1,188 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Eis.Infrastructure.Authentication; + +internal sealed class TotpCompatibilityService(AuthenticationOptions options) +{ + private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + private const string RecoveryAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; + private const long PeriodSeconds = 30; + private readonly byte[] _encryptionKey = SHA256.HashData(Encoding.UTF8.GetBytes(options.TotpEncryptionMaterial)); + + public string CreateSecret() => EncodeBase32(RandomNumberGenerator.GetBytes(20)); + + public long? Verify(string? code, string secret, long? lastUsedStep = null, DateTimeOffset? now = null) + { + var normalized = string.Concat((code ?? string.Empty).Where(character => !char.IsWhiteSpace(character))); + if (normalized.Length != 6 || normalized.Any(character => !char.IsAsciiDigit(character))) + { + return null; + } + + var currentStep = (now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds() / PeriodSeconds; + for (var offset = -1; offset <= 1; offset++) + { + var step = currentStep + offset; + if (lastUsedStep is not null && step <= lastUsedStep.Value) + { + continue; + } + + var expected = Encoding.ASCII.GetBytes(AtStep(secret, step)); + var supplied = Encoding.ASCII.GetBytes(normalized); + if (CryptographicOperations.FixedTimeEquals(expected, supplied)) + { + return step; + } + } + + return null; + } + + public string AtStep(string secret, long step) + { + Span counter = stackalloc byte[8]; + BinaryPrimitives.WriteInt64BigEndian(counter, step); + var digest = HMACSHA1.HashData(DecodeBase32(secret), counter); + var offset = digest[^1] & 0x0f; + var binary = (BinaryPrimitives.ReadInt32BigEndian(digest.AsSpan(offset, 4)) & 0x7fffffff) % 1_000_000; + return binary.ToString("D6", CultureInfo.InvariantCulture); + } + + public string BuildOtpAuthUri(string secret, string account, string issuer) + { + var label = Uri.EscapeDataString($"{issuer}:{account}"); + return $"otpauth://totp/{label}?secret={FormEncode(secret)}&issuer={FormEncode(issuer)}&algorithm=SHA1&digits=6&period=30"; + } + + public string EncryptSecret(string secret) + { + var nonce = RandomNumberGenerator.GetBytes(12); + var plaintext = Encoding.UTF8.GetBytes(secret); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[16]; + using var aes = new AesGcm(_encryptionKey, tag.Length); + aes.Encrypt(nonce, plaintext, ciphertext, tag); + return $"v1.{Base64UrlEncode(nonce)}.{Base64UrlEncode(tag)}.{Base64UrlEncode(ciphertext)}"; + } + + public string DecryptSecret(string value) + { + var parts = value.Split('.'); + if (parts.Length != 4 || parts[0] != "v1" || parts.Skip(1).Any(string.IsNullOrEmpty)) + { + throw new CryptographicException("TOTP 密钥数据无效"); + } + + var nonce = Base64UrlDecode(parts[1]); + var tag = Base64UrlDecode(parts[2]); + var ciphertext = Base64UrlDecode(parts[3]); + var plaintext = new byte[ciphertext.Length]; + using var aes = new AesGcm(_encryptionKey, tag.Length); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + return Encoding.UTF8.GetString(plaintext); + } + + public IReadOnlyList CreateRecoveryCodes(int count = 8) + { + var output = new List(count); + for (var item = 0; item < count; item++) + { + var bytes = RandomNumberGenerator.GetBytes(10); + var value = string.Concat(bytes.Select(value => RecoveryAlphabet[value % RecoveryAlphabet.Length])); + output.Add($"{value[..5]}-{value[5..]}"); + } + + return output; + } + + public string HashRecoveryCode(string? code) + { + var normalized = string.Concat((code ?? string.Empty) + .ToUpperInvariant() + .Where(char.IsAsciiLetterOrDigit)); + return Convert.ToHexStringLower(HMACSHA256.HashData(_encryptionKey, Encoding.UTF8.GetBytes(normalized))); + } + + public IReadOnlyList? ConsumeRecoveryCode(string? code, IReadOnlyList hashes) + { + var candidate = Encoding.ASCII.GetBytes(HashRecoveryCode(code)); + for (var index = 0; index < hashes.Count; index++) + { + var stored = Encoding.ASCII.GetBytes(hashes[index] ?? string.Empty); + if (stored.Length == candidate.Length && CryptographicOperations.FixedTimeEquals(stored, candidate)) + { + return hashes.Where((_, itemIndex) => itemIndex != index).ToArray(); + } + } + + return null; + } + + private static string FormEncode(string value) => Uri.EscapeDataString(value).Replace("%20", "+", StringComparison.Ordinal); + + private static string EncodeBase32(ReadOnlySpan bytes) + { + var output = new StringBuilder((bytes.Length * 8 + 4) / 5); + var buffer = 0; + var bits = 0; + foreach (var value in bytes) + { + buffer = (buffer << 8) | value; + bits += 8; + while (bits >= 5) + { + bits -= 5; + output.Append(Base32Alphabet[(buffer >> bits) & 31]); + } + } + + if (bits > 0) + { + output.Append(Base32Alphabet[(buffer << (5 - bits)) & 31]); + } + + return output.ToString(); + } + + private static byte[] DecodeBase32(string value) + { + var normalized = string.Concat(value.ToUpperInvariant().Where(character => Base32Alphabet.Contains(character))); + var output = new List(); + var buffer = 0; + var bits = 0; + foreach (var character in normalized) + { + var index = Base32Alphabet.IndexOf(character, StringComparison.Ordinal); + if (index < 0) + { + throw new FormatException("TOTP 密钥格式无效"); + } + + buffer = (buffer << 5) | index; + bits += 5; + if (bits >= 8) + { + bits -= 8; + output.Add((byte)((buffer >> bits) & 0xff)); + } + } + + return output.ToArray(); + } + + private static string Base64UrlEncode(byte[] value) => Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private static byte[] Base64UrlDecode(string value) + { + var padded = value.Replace('-', '+').Replace('_', '/'); + padded += new string('=', (4 - padded.Length % 4) % 4); + return Convert.FromBase64String(padded); + } +} diff --git a/src/Eis.Infrastructure/Data/RelationalConnectionFactory.cs b/src/Eis.Infrastructure/Data/RelationalConnectionFactory.cs index 9dfd12a..216b99f 100644 --- a/src/Eis.Infrastructure/Data/RelationalConnectionFactory.cs +++ b/src/Eis.Infrastructure/Data/RelationalConnectionFactory.cs @@ -13,7 +13,7 @@ public sealed class RelationalConnectionFactory(DatabaseOptions options) : IRela "sqlite" => new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = options.SqlitePath, - Mode = SqliteOpenMode.ReadOnly, + Mode = SqliteOpenMode.ReadWrite, Cache = SqliteCacheMode.Shared, ForeignKeys = true, DefaultTimeout = 5 diff --git a/src/Eis.Infrastructure/DependencyInjection.cs b/src/Eis.Infrastructure/DependencyInjection.cs index 91e20db..d4ca546 100644 --- a/src/Eis.Infrastructure/DependencyInjection.cs +++ b/src/Eis.Infrastructure/DependencyInjection.cs @@ -1,4 +1,6 @@ +using Eis.Application.Authentication; using Eis.Application.Public; +using Eis.Infrastructure.Authentication; using Eis.Infrastructure.Data; using Eis.Infrastructure.Public; using Eis.Infrastructure.Security; @@ -8,15 +10,35 @@ namespace Eis.Infrastructure; public static class DependencyInjection { + public static void EnsureNativeAuthenticationReady( + this IServiceProvider serviceProvider, + AuthenticationOptions authenticationOptions) + { + if (authenticationOptions.NativeEnabled) + { + _ = serviceProvider.GetRequiredService(); + } + } + public static IServiceCollection AddEisInfrastructure( this IServiceCollection services, DatabaseOptions databaseOptions, - DocumentVerificationOptions documentVerificationOptions) + DocumentVerificationOptions documentVerificationOptions, + AuthenticationOptions authenticationOptions) { services.AddSingleton(databaseOptions); services.AddSingleton(); services.AddSingleton(documentVerificationOptions); services.AddSingleton(); + services.AddSingleton(authenticationOptions); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => + authenticationOptions.UsesRedis + ? new RedisAuthenticationStateStore(authenticationOptions) + : new MemoryAuthenticationStateStore(authenticationOptions)); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } diff --git a/src/Eis.Infrastructure/Eis.Infrastructure.csproj b/src/Eis.Infrastructure/Eis.Infrastructure.csproj index 2c52f3c..8149b61 100644 --- a/src/Eis.Infrastructure/Eis.Infrastructure.csproj +++ b/src/Eis.Infrastructure/Eis.Infrastructure.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs b/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs index 90a3271..ce7eaad 100644 --- a/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs +++ b/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs @@ -5,10 +5,10 @@ namespace Eis.Infrastructure.Migration; public static class MigrationFeatureCatalog { - public static IReadOnlyList Current { get; } = + public static IReadOnlyList Current(bool authenticationNative) => [ new(FeatureArea.Public, true, "/api/public"), - new(FeatureArea.Authentication, false, "/api/auth"), + new(FeatureArea.Authentication, authenticationNative, "/api/auth"), new(FeatureArea.Candidate, false, "/api/candidate"), new(FeatureArea.Administration, false, "/api/admin"), new(FeatureArea.Admission, false, "/api/admission"), diff --git a/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs b/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs index 1b19f79..1b71238 100644 --- a/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs +++ b/src/Eis.Infrastructure/Public/PublicQueryService.Announcements.cs @@ -168,20 +168,20 @@ public sealed partial class PublicQueryService AdmissionRecordRow item, IReadOnlyDictionary examNames, IReadOnlyDictionary schoolNames) => new() - { - ["id"] = item.Id, - ["examId"] = item.ExamId, - ["examName"] = examNames.GetValueOrDefault(item.ExamId) ?? string.Empty, - ["schoolName"] = item.SchoolId is null ? string.Empty : schoolNames.GetValueOrDefault(item.SchoolId) ?? string.Empty, - ["publishedAt"] = GetString(item.Payload, "publishedAt") ?? item.UpdatedAt, - ["rows"] = new JsonArray(GetArray(item.Payload, "rows").OfType().Select(row => new JsonObject { - ["registrationNumber"] = GetString(row, "registrationNumber") ?? string.Empty, - ["name"] = GetString(row, "name") ?? string.Empty, - ["eligible"] = GetBoolean(row, "eligible", false), - ["specialtyLabel"] = GetString(row, "specialtyLabel") ?? "普通生" - }).ToArray()) - }; + ["id"] = item.Id, + ["examId"] = item.ExamId, + ["examName"] = examNames.GetValueOrDefault(item.ExamId) ?? string.Empty, + ["schoolName"] = item.SchoolId is null ? string.Empty : schoolNames.GetValueOrDefault(item.SchoolId) ?? string.Empty, + ["publishedAt"] = GetString(item.Payload, "publishedAt") ?? item.UpdatedAt, + ["rows"] = new JsonArray(GetArray(item.Payload, "rows").OfType().Select(row => new JsonObject + { + ["registrationNumber"] = GetString(row, "registrationNumber") ?? string.Empty, + ["name"] = GetString(row, "name") ?? string.Empty, + ["eligible"] = GetBoolean(row, "eligible", false), + ["specialtyLabel"] = GetString(row, "specialtyLabel") ?? "普通生" + }).ToArray()) + }; private static bool QualificationComplete( AdmissionRecordRow publication, diff --git a/src/Eis.Web/Authentication/NativeAuthenticationEndpoints.cs b/src/Eis.Web/Authentication/NativeAuthenticationEndpoints.cs new file mode 100644 index 0000000..c35eb46 --- /dev/null +++ b/src/Eis.Web/Authentication/NativeAuthenticationEndpoints.cs @@ -0,0 +1,199 @@ +using Eis.Application.Authentication; +using Eis.Infrastructure.Authentication; + +namespace Eis.Web.Authentication; + +public static class NativeAuthenticationEndpoints +{ + private const string SessionCookieName = "hz_session"; + + public static IEndpointRouteBuilder MapNativeAuthenticationEndpoints( + this IEndpointRouteBuilder endpoints, + AuthenticationOptions options) + { + if (!options.NativeEnabled) + { + return endpoints; + } + + endpoints.MapPost("/api/auth/register", async ( + HttpContext context, + RegisterRequest request, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.RegisterAsync( + request.Name ?? string.Empty, + request.Gender ?? string.Empty, + request.Password ?? string.Empty, + request.SchoolId ?? string.Empty, + request.ClassId ?? string.Empty, + cancellationToken))); + + endpoints.MapGet("/api/auth/me", async ( + HttpContext context, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.GetCurrentUserAsync(SessionToken(context), cancellationToken))); + + endpoints.MapPost("/api/auth/login", async ( + HttpContext context, + LoginRequest request, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.LoginAsync(request.Username ?? string.Empty, request.Password ?? string.Empty, cancellationToken))); + + endpoints.MapPost("/api/auth/login/totp", async ( + HttpContext context, + TotpLoginRequest request, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.CompleteTotpLoginAsync( + request.Challenge ?? string.Empty, + request.Code ?? string.Empty, + cancellationToken))); + + endpoints.MapPost("/api/auth/change-password", async ( + HttpContext context, + ChangePasswordRequest request, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.ChangePasswordAsync( + SessionToken(context), + request.CurrentPassword ?? string.Empty, + request.NewPassword ?? string.Empty, + cancellationToken))); + + endpoints.MapGet("/api/auth/totp", async ( + HttpContext context, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.GetTotpStatusAsync(SessionToken(context), cancellationToken))); + + endpoints.MapPost("/api/auth/totp/setup", async ( + HttpContext context, + PasswordRequest request, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.BeginTotpSetupAsync( + SessionToken(context), + request.CurrentPassword ?? string.Empty, + cancellationToken))); + + endpoints.MapPost("/api/auth/totp/enable", async ( + HttpContext context, + CodeRequest request, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.EnableTotpAsync( + SessionToken(context), + request.Code ?? string.Empty, + cancellationToken))); + + endpoints.MapPost("/api/auth/totp/recovery-codes", async ( + HttpContext context, + PasswordAndCodeRequest request, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.RegenerateRecoveryCodesAsync( + SessionToken(context), + request.CurrentPassword ?? string.Empty, + request.Code ?? string.Empty, + cancellationToken))); + + endpoints.MapPost("/api/auth/totp/disable", async ( + HttpContext context, + PasswordAndCodeRequest request, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.DisableTotpAsync( + SessionToken(context), + request.CurrentPassword ?? string.Empty, + request.Code ?? string.Empty, + cancellationToken))); + + endpoints.MapPost("/api/auth/logout", async ( + HttpContext context, + IAuthenticationService service, + CancellationToken cancellationToken) => ToResult( + context, + await service.LogoutAsync(SessionToken(context), cancellationToken))); + + return endpoints; + } + + private static string SessionToken(HttpContext context) => + context.Request.Cookies.TryGetValue(SessionCookieName, out var token) ? token : string.Empty; + + private static IResult ToResult(HttpContext context, AuthenticationEndpointResult result) + { + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers["X-EIS-Implementation"] = "aspnet-core"; + if (result.SetCookie is not null) + { + context.Response.Headers.Append("Set-Cookie", result.SetCookie); + } + + return Results.Json(result.Body, statusCode: result.StatusCode); + } + + private sealed class LoginRequest + { + public string? Username { get; init; } + + public string? Password { get; init; } + } + + private sealed class RegisterRequest + { + public string? Name { get; init; } + + public string? Gender { get; init; } + + public string? Password { get; init; } + + public string? SchoolId { get; init; } + + public string? ClassId { get; init; } + } + + private sealed class TotpLoginRequest + { + public string? Challenge { get; init; } + + public string? Code { get; init; } + } + + private sealed class ChangePasswordRequest + { + public string? CurrentPassword { get; init; } + + public string? NewPassword { get; init; } + } + + private sealed class PasswordRequest + { + public string? CurrentPassword { get; init; } + } + + private sealed class CodeRequest + { + public string? Code { get; init; } + } + + private sealed class PasswordAndCodeRequest + { + public string? CurrentPassword { get; init; } + + public string? Code { get; init; } + } +} diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index a9820a6..3bcd004 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -1,10 +1,12 @@ using System.Net; +using Eis.Infrastructure.Authentication; using Eis.Application.Public; using Eis.Infrastructure; using Eis.Infrastructure.Data; using Eis.Infrastructure.Migration; using Eis.Infrastructure.Security; using Eis.Web.Configuration; +using Eis.Web.Authentication; using Eis.Web.Frontend; using Eis.Web.Legacy; using Eis.Web.Public; @@ -29,11 +31,16 @@ builder.Services.AddHttpClient((services, client) => }); builder.Services.AddProblemDetails(); builder.Services.AddSingleton(); +var authenticationOptions = AuthenticationOptions.FromEnvironment( + builder.Environment.IsProduction(), + builder.Configuration.GetValue("AuthenticationMigration:NativeEnabled")); builder.Services.AddEisInfrastructure( DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), - DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction())); + DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()), + authenticationOptions); var app = builder.Build(); +app.Services.EnsureNativeAuthenticationReady(authenticationOptions); app.UseExceptionHandler(); app.Use(async (context, next) => @@ -59,11 +66,18 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c { status = legacyAvailable ? "healthy" : "degraded", legacyApiAvailable = legacyAvailable, - features = MigrationFeatureCatalog.Current + authentication = new + { + nativeEnabled = authenticationOptions.NativeEnabled, + stateBackend = authenticationOptions.UsesRedis ? "redis" : "memory", + sharesLegacySessions = authenticationOptions.SharesLegacySessions + }, + features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled) }, statusCode: statusCode); }); app.MapNativePublicEndpoints(); +app.MapNativeAuthenticationEndpoints(authenticationOptions); string[] methods = [ diff --git a/src/Eis.Web/appsettings.json b/src/Eis.Web/appsettings.json index 3750199..1545cc6 100644 --- a/src/Eis.Web/appsettings.json +++ b/src/Eis.Web/appsettings.json @@ -3,6 +3,9 @@ "Enabled": true, "BaseUrl": "http://127.0.0.1:4174" }, + "AuthenticationMigration": { + "NativeEnabled": false + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/tests/Eis.Infrastructure.Tests/Authentication/AuthenticationStateStoreTests.cs b/tests/Eis.Infrastructure.Tests/Authentication/AuthenticationStateStoreTests.cs new file mode 100644 index 0000000..98bc24d --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Authentication/AuthenticationStateStoreTests.cs @@ -0,0 +1,48 @@ +using Eis.Infrastructure.Authentication; + +namespace Eis.Infrastructure.Tests.Authentication; + +public sealed class AuthenticationStateStoreTests +{ + [Fact] + public async Task MemoryStoreHandlesSessionsChallengesAndTotpSetups() + { + var options = AuthenticationOptions.CreateForTests("test-only-totp-encryption-key-32-characters"); + var state = new MemoryAuthenticationStateStore(options); + + await state.CreateSessionAsync("session-1", "user-1"); + await state.CreateSessionAsync("session-2", "user-1"); + Assert.Equal("user-1", await state.GetSessionUserIdAsync("session-1")); + + await state.CreateLoginChallengeAsync("challenge-1", "user-1"); + for (var attempt = 1; attempt <= 5; attempt++) + { + var failure = await state.RecordLoginChallengeFailureAsync("challenge-1", 5); + Assert.Equal(attempt, failure?.Attempts); + } + Assert.Null(await state.GetLoginChallengeAsync("challenge-1")); + + await state.CreateTotpSetupAsync("session-1", "user-1", "SECRET"); + Assert.Equal(new TotpSetup("user-1", "SECRET"), await state.GetTotpSetupAsync("session-1")); + await state.DeleteTotpSetupAsync("session-1"); + Assert.Null(await state.GetTotpSetupAsync("session-1")); + + Assert.Equal(2, await state.DeleteUserSessionsAsync("user-1")); + Assert.Null(await state.GetSessionUserIdAsync("session-1")); + Assert.Null(await state.GetSessionUserIdAsync("session-2")); + } + + [Fact] + public void RedisUrlMapsCredentialsTlsAndEndpoint() + { + var configuration = RedisAuthenticationStateStore.BuildConfiguration( + "rediss://session-user:p%40ss@example.test:6380/4"); + var endpoint = Assert.Single(configuration.EndPoints); + + Assert.Equal("Unspecified/example.test:6380", endpoint.ToString()); + Assert.True(configuration.Ssl); + Assert.Equal("example.test", configuration.SslHost); + Assert.Equal("session-user", configuration.User); + Assert.Equal("p@ss", configuration.Password); + } +} diff --git a/tests/Eis.Infrastructure.Tests/Authentication/PasswordCompatibilityServiceTests.cs b/tests/Eis.Infrastructure.Tests/Authentication/PasswordCompatibilityServiceTests.cs new file mode 100644 index 0000000..ce468b9 --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Authentication/PasswordCompatibilityServiceTests.cs @@ -0,0 +1,29 @@ +using Eis.Infrastructure.Authentication; + +namespace Eis.Infrastructure.Tests.Authentication; + +public sealed class PasswordCompatibilityServiceTests +{ + [Fact] + public void VerifiesNodePbkdf2PasswordHash() + { + const string stored = "00112233445566778899aabbccddeeff:7a69c21675902559aa0cae041a3b4ebb3bc1402bb70a753eff44f5b32543c270"; + var service = new PasswordCompatibilityService(); + + Assert.True(service.Verify("兼容Password123!", stored)); + Assert.False(service.Verify("wrong-password", stored)); + } + + [Fact] + public void CreatesHashUsingLegacySaltAndDigestShape() + { + var service = new PasswordCompatibilityService(); + var stored = service.Hash("Password123!"); + var parts = stored.Split(':'); + + Assert.Equal(2, parts.Length); + Assert.Equal(32, parts[0].Length); + Assert.Equal(64, parts[1].Length); + Assert.True(service.Verify("Password123!", stored)); + } +} diff --git a/tests/Eis.Infrastructure.Tests/Authentication/TotpCompatibilityServiceTests.cs b/tests/Eis.Infrastructure.Tests/Authentication/TotpCompatibilityServiceTests.cs new file mode 100644 index 0000000..e278799 --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Authentication/TotpCompatibilityServiceTests.cs @@ -0,0 +1,47 @@ +using Eis.Infrastructure.Authentication; + +namespace Eis.Infrastructure.Tests.Authentication; + +public sealed class TotpCompatibilityServiceTests +{ + private const string EncryptionKey = "test-only-totp-encryption-key-32-characters"; + private readonly TotpCompatibilityService _service = new(AuthenticationOptions.CreateForTests(EncryptionKey)); + + [Fact] + public void MatchesRfc6238NodeVector() + { + Assert.Equal("287082", _service.AtStep("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 1)); + } + + [Fact] + public void DecryptsNodeAesGcmPayloadAndRoundTripsDotnetPayload() + { + const string nodePayload = "v1.BW3b8OSqNLprCfpr.UUw1AKfqGvmo11YQUhHuPQ.GbPra-HNEUS3uWPJqwVpw2utLYbs89rlPOwhuNl7QYk"; + const string secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + + Assert.Equal(secret, _service.DecryptSecret(nodePayload)); + Assert.Equal(secret, _service.DecryptSecret(_service.EncryptSecret(secret))); + } + + [Fact] + public void MatchesNodeRecoveryHashAndOtpAuthUri() + { + Assert.Equal( + "662bf005217b529934526e0c8755c0c48d4dab012dfe96191080a6675334559c", + _service.HashRecoveryCode("ABCDE-23456")); + Assert.Equal( + "otpauth://totp/%E6%B5%B7%E5%B7%9E%20%E8%80%83%E8%AF%95%E4%B8%AD%E5%BF%83%3A2026%200001?secret=ABCDEF234567&issuer=%E6%B5%B7%E5%B7%9E+%E8%80%83%E8%AF%95%E4%B8%AD%E5%BF%83&algorithm=SHA1&digits=6&period=30", + _service.BuildOtpAuthUri("ABCDEF234567", "2026 0001", "海州 考试中心")); + } + + [Fact] + public void RecoveryCodeCanOnlyBeConsumedOnce() + { + var hashes = new[] { _service.HashRecoveryCode("ABCDE-23456"), _service.HashRecoveryCode("FGHJK-78923") }; + var remaining = _service.ConsumeRecoveryCode("abcde 23456", hashes); + + Assert.NotNull(remaining); + Assert.Single(remaining); + Assert.Null(_service.ConsumeRecoveryCode("ABCDE-23456", remaining)); + } +} diff --git a/tests/helpers/current-totp-code.mjs b/tests/helpers/current-totp-code.mjs new file mode 100644 index 0000000..fdf7075 --- /dev/null +++ b/tests/helpers/current-totp-code.mjs @@ -0,0 +1,5 @@ +import { totpAtStep } from '../../src/security/totp.mjs'; + +const secret = String(process.argv[2] || ''); +if (!secret) throw new Error('TOTP secret is required'); +console.log(totpAtStep(secret, Math.floor(Date.now() / 1000 / 30))); diff --git a/tests/helpers/enable-self-registration.mjs b/tests/helpers/enable-self-registration.mjs new file mode 100644 index 0000000..48804b7 --- /dev/null +++ b/tests/helpers/enable-self-registration.mjs @@ -0,0 +1,8 @@ +import { DatabaseSync } from 'node:sqlite'; + +const databasePath = process.argv[2]; +if (!databasePath) throw new Error('缺少 SQLite 测试数据库路径'); + +const database = new DatabaseSync(databasePath); +database.prepare('UPDATE schema_metadata SET self_registration_enabled = 1 WHERE id = 1').run(); +database.close();