自主注册、登录、退出、当前用户与密码修改

兼容现有 PBKDF2 密码和 hz_session Cookie
TOTP 绑定、二步登录、防重放、恢复码与 AES-GCM 密钥
与 Node 完全一致的 Redis 键格式,可跨运行时共享会话
生产环境开启原生认证时强制要求 Redis
默认保持兼容代理;设置 AUTH_NATIVE_ENABLED=true 即可切换
This commit is contained in:
2026-07-22 19:34:11 +08:00 Unverified
parent 017cacc6f9
commit ae472aabb0
26 changed files with 2569 additions and 21 deletions
+6
View File
@@ -15,9 +15,15 @@ PORT=4173
# REDIS_SESSION_DB=1 # REDIS_SESSION_DB=1
# REDIS_SESSION_PREFIX=exam-information:auth # REDIS_SESSION_PREFIX=exam-information:auth
# AUTH_SESSION_TTL_SECONDS=28800 # AUTH_SESSION_TTL_SECONDS=28800
# AUTH_LOGIN_CHALLENGE_TTL_SECONDS=300
# AUTH_TOTP_SETUP_TTL_SECONDS=600
# 如需让认证状态使用另一台 Redis,可设置独立地址;URL 中可直接指定逻辑 DB。 # 如需让认证状态使用另一台 Redis,可设置独立地址;URL 中可直接指定逻辑 DB。
# REDIS_SESSION_URL=rediss://session-redis.example.com:6379/1 # 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_USERNAME=admin
INITIAL_ADMIN_PASSWORD=Admin123! INITIAL_ADMIN_PASSWORD=Admin123!
+2
View File
@@ -6,7 +6,9 @@
<PackageVersion Include="AngleSharp" Version="1.5.2" /> <PackageVersion Include="AngleSharp" Version="1.5.2" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" /> <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
<PackageVersion Include="MySqlConnector" Version="2.6.1" /> <PackageVersion Include="MySqlConnector" Version="2.6.1" />
<PackageVersion Include="QRCoder" Version="1.8.0" />
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" /> <PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
<PackageVersion Include="StackExchange.Redis" Version="3.0.17" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
+9 -1
View File
@@ -10,7 +10,7 @@
- [x] 存活与迁移就绪检查 - [x] 存活与迁移就绪检查
- [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取) - [x] 公开首页与已发布通知(原生 SQLite / MySQL 读取)
- [x] 招生公示与 HMAC 文书验真公开接口 - [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` 可通过配置 `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 冒烟测试: 完整的宿主、静态资源、JSON 转发和 Session Cookie 冒烟测试:
```powershell ```powershell
+138 -1
View File
@@ -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')) $testDirectory = Join-Path $smokeArtifactRoot ("eis-migration-smoke-{0}" -f [guid]::NewGuid().ToString('N'))
$nodeProcess = $null $nodeProcess = $null
$dotnetProcess = $null $dotnetProcess = $null
$nativeAuthProcess = $null
function Get-AvailableTcpPort { function Get-AvailableTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
@@ -88,8 +89,10 @@ try {
$legacyPort = Get-AvailableTcpPort $legacyPort = Get-AvailableTcpPort
$webPort = Get-AvailableTcpPort $webPort = Get-AvailableTcpPort
$nativeAuthPort = Get-AvailableTcpPort
$legacyBaseUrl = "http://127.0.0.1:$legacyPort" $legacyBaseUrl = "http://127.0.0.1:$legacyPort"
$webBaseUrl = "http://127.0.0.1:$webPort" $webBaseUrl = "http://127.0.0.1:$webPort"
$nativeAuthBaseUrl = "http://127.0.0.1:$nativeAuthPort"
$nodeExecutable = (Get-Command node.exe -ErrorAction Stop).Source $nodeExecutable = (Get-Command node.exe -ErrorAction Stop).Source
$dotnetExecutable = (Get-Command dotnet.exe -ErrorAction Stop).Source $dotnetExecutable = (Get-Command dotnet.exe -ErrorAction Stop).Source
@@ -120,6 +123,20 @@ try {
} }
$seedProcess.Dispose() $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 $nodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('server.mjs') -Environment $nodeEnvironment
$projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj' $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' 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]@{ [pscustomobject]@{
AspNetCoreHost = 'passed' AspNetCoreHost = 'passed'
StaticAssets = 'passed' StaticAssets = 'passed'
@@ -288,10 +424,11 @@ try {
NativePublicApi = 'passed' NativePublicApi = 'passed'
PublicParity = 'passed' PublicParity = 'passed'
DocumentCodes = 'passed' DocumentCodes = 'passed'
NativeAuthentication = 'passed'
} | Format-List } | Format-List
} }
finally { finally {
foreach ($process in @($dotnetProcess, $nodeProcess)) { foreach ($process in @($nativeAuthProcess, $dotnetProcess, $nodeProcess)) {
if ($null -ne $process -and -not $process.HasExited) { if ($null -ne $process -and -not $process.HasExited) {
$process.Kill($true) $process.Kill($true)
$process.WaitForExit() $process.WaitForExit()
@@ -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<AuthenticationEndpointResult> RegisterAsync(
string name,
string gender,
string password,
string schoolId,
string classId,
CancellationToken cancellationToken);
Task<AuthenticationEndpointResult> GetCurrentUserAsync(string sessionToken, CancellationToken cancellationToken);
Task<AuthenticationEndpointResult> LoginAsync(string username, string password, CancellationToken cancellationToken);
Task<AuthenticationEndpointResult> CompleteTotpLoginAsync(
string challenge,
string code,
CancellationToken cancellationToken);
Task<AuthenticationEndpointResult> ChangePasswordAsync(
string sessionToken,
string currentPassword,
string newPassword,
CancellationToken cancellationToken);
Task<AuthenticationEndpointResult> GetTotpStatusAsync(string sessionToken, CancellationToken cancellationToken);
Task<AuthenticationEndpointResult> BeginTotpSetupAsync(
string sessionToken,
string currentPassword,
CancellationToken cancellationToken);
Task<AuthenticationEndpointResult> EnableTotpAsync(
string sessionToken,
string code,
CancellationToken cancellationToken);
Task<AuthenticationEndpointResult> RegenerateRecoveryCodesAsync(
string sessionToken,
string currentPassword,
string code,
CancellationToken cancellationToken);
Task<AuthenticationEndpointResult> DisableTotpAsync(
string sessionToken,
string currentPassword,
string code,
CancellationToken cancellationToken);
Task<AuthenticationEndpointResult> LogoutAsync(string sessionToken, CancellationToken cancellationToken);
}
@@ -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}";
}
}
@@ -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<string> 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<AuthenticationUser?> 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<AuthenticationUser?> FindUserByIdAsync(string id, CancellationToken cancellationToken) =>
QueryUserAsync(
$"SELECT {UserColumns} FROM users WHERE id = @id LIMIT 1",
[new("@id", id)],
cancellationToken);
public async Task<RegistrationCreationResult> 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<NumberRuleSegment>();
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<string>();
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<JsonObject?> 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<string> 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<string> 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<AuthenticationUser?> QueryUserAsync(
string sql,
IReadOnlyList<SqlParameterValue> 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<SqlParameterValue> 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<string?> 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<object?> ScalarAsync(
DbConnection connection,
DbTransaction transaction,
string sql,
IReadOnlyList<SqlParameterValue> 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<SqlParameterValue> 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<string> ParseStringArray(string? value)
{
try
{
return JsonSerializer.Deserialize<string[]>(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<char> 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);
}
@@ -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<string, string[]> PermissionsByLevel =
new Dictionary<string, string[]>(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<AuthenticationEndpointResult> 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<AuthenticationEndpointResult> 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<AuthenticationEndpointResult> 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<AuthenticationEndpointResult> 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<AuthenticationEndpointResult> 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<AuthenticationEndpointResult> 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<AuthenticationEndpointResult> 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<AuthenticationEndpointResult> 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<AuthenticationEndpointResult> 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<AuthenticationEndpointResult> 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<AuthenticationEndpointResult> 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<AuthenticationUser?> 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<AuthenticationEndpointResult> 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<char> 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<string>? RecoveryCodes);
}
@@ -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<string?> GetSessionUserIdAsync(string token);
Task DeleteSessionAsync(string token);
Task<int> DeleteUserSessionsAsync(string userId);
Task<int> DeleteUsersSessionsAsync(IEnumerable<string> userIds);
Task CreateLoginChallengeAsync(string key, string userId);
Task<LoginChallenge?> GetLoginChallengeAsync(string key);
Task<LoginChallenge?> RecordLoginChallengeFailureAsync(string key, int maximumAttempts);
Task DeleteLoginChallengeAsync(string key);
Task CreateTotpSetupAsync(string token, string userId, string secret);
Task<TotpSetup?> GetTotpSetupAsync(string token);
Task DeleteTotpSetupAsync(string token);
}
@@ -0,0 +1,155 @@
using System.Collections.Concurrent;
namespace Eis.Infrastructure.Authentication;
internal sealed class MemoryAuthenticationStateStore(AuthenticationOptions options) : IAuthenticationStateStore
{
private readonly ConcurrentDictionary<string, ExpiringSession> _sessions = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ExpiringChallenge> _challenges = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ExpiringSetup> _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<string?> GetSessionUserIdAsync(string token)
{
if (!_sessions.TryGetValue(token, out var item) || item.ExpiresAt <= DateTimeOffset.UtcNow)
{
_sessions.TryRemove(token, out _);
return Task.FromResult<string?>(null);
}
return Task.FromResult<string?>(item.UserId);
}
public Task DeleteSessionAsync(string token)
{
_sessions.TryRemove(token, out _);
return Task.CompletedTask;
}
public Task<int> 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<int> DeleteUsersSessionsAsync(IEnumerable<string> 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<LoginChallenge?> GetLoginChallengeAsync(string key)
{
if (!TryGetLive(_challenges, key, out var item))
{
return Task.FromResult<LoginChallenge?>(null);
}
return Task.FromResult<LoginChallenge?>(new LoginChallenge(item.UserId, item.Attempts));
}
public Task<LoginChallenge?> 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<LoginChallenge?>(new LoginChallenge(updated.UserId, updated.Attempts));
}
return Task.FromResult<LoginChallenge?>(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<TotpSetup?> GetTotpSetupAsync(string token)
{
if (!TryGetLive(_setups, token, out var item))
{
return Task.FromResult<TotpSetup?>(null);
}
return Task.FromResult<TotpSetup?>(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<T>(ConcurrentDictionary<string, T> 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;
}
@@ -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);
}
@@ -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<string?> 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<int> 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<int> DeleteUsersSessionsAsync(IEnumerable<string> 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<LoginChallenge?> 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<LoginChallenge?> 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<TotpSetup?> 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;
}
}
@@ -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<byte> 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<string> CreateRecoveryCodes(int count = 8)
{
var output = new List<string>(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<string>? ConsumeRecoveryCode(string? code, IReadOnlyList<string> 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<byte> 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<byte>();
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);
}
}
@@ -13,7 +13,7 @@ public sealed class RelationalConnectionFactory(DatabaseOptions options) : IRela
"sqlite" => new SqliteConnection(new SqliteConnectionStringBuilder "sqlite" => new SqliteConnection(new SqliteConnectionStringBuilder
{ {
DataSource = options.SqlitePath, DataSource = options.SqlitePath,
Mode = SqliteOpenMode.ReadOnly, Mode = SqliteOpenMode.ReadWrite,
Cache = SqliteCacheMode.Shared, Cache = SqliteCacheMode.Shared,
ForeignKeys = true, ForeignKeys = true,
DefaultTimeout = 5 DefaultTimeout = 5
+23 -1
View File
@@ -1,4 +1,6 @@
using Eis.Application.Authentication;
using Eis.Application.Public; using Eis.Application.Public;
using Eis.Infrastructure.Authentication;
using Eis.Infrastructure.Data; using Eis.Infrastructure.Data;
using Eis.Infrastructure.Public; using Eis.Infrastructure.Public;
using Eis.Infrastructure.Security; using Eis.Infrastructure.Security;
@@ -8,15 +10,35 @@ namespace Eis.Infrastructure;
public static class DependencyInjection public static class DependencyInjection
{ {
public static void EnsureNativeAuthenticationReady(
this IServiceProvider serviceProvider,
AuthenticationOptions authenticationOptions)
{
if (authenticationOptions.NativeEnabled)
{
_ = serviceProvider.GetRequiredService<IAuthenticationStateStore>();
}
}
public static IServiceCollection AddEisInfrastructure( public static IServiceCollection AddEisInfrastructure(
this IServiceCollection services, this IServiceCollection services,
DatabaseOptions databaseOptions, DatabaseOptions databaseOptions,
DocumentVerificationOptions documentVerificationOptions) DocumentVerificationOptions documentVerificationOptions,
AuthenticationOptions authenticationOptions)
{ {
services.AddSingleton(databaseOptions); services.AddSingleton(databaseOptions);
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>(); services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
services.AddSingleton(documentVerificationOptions); services.AddSingleton(documentVerificationOptions);
services.AddSingleton<DocumentVerificationCodeService>(); services.AddSingleton<DocumentVerificationCodeService>();
services.AddSingleton(authenticationOptions);
services.AddSingleton<PasswordCompatibilityService>();
services.AddSingleton<TotpCompatibilityService>();
services.AddSingleton<IAuthenticationStateStore>(provider =>
authenticationOptions.UsesRedis
? new RedisAuthenticationStateStore(authenticationOptions)
: new MemoryAuthenticationStateStore(authenticationOptions));
services.AddScoped<AuthenticationRepository>();
services.AddScoped<IAuthenticationService, AuthenticationService>();
services.AddScoped<IPublicQueryService, PublicQueryService>(); services.AddScoped<IPublicQueryService, PublicQueryService>();
return services; return services;
} }
@@ -11,6 +11,8 @@
<PackageReference Include="AngleSharp" /> <PackageReference Include="AngleSharp" />
<PackageReference Include="Microsoft.Data.Sqlite" /> <PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="MySqlConnector" /> <PackageReference Include="MySqlConnector" />
<PackageReference Include="QRCoder" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -5,10 +5,10 @@ namespace Eis.Infrastructure.Migration;
public static class MigrationFeatureCatalog public static class MigrationFeatureCatalog
{ {
public static IReadOnlyList<MigrationFeature> Current { get; } = public static IReadOnlyList<MigrationFeature> Current(bool authenticationNative) =>
[ [
new(FeatureArea.Public, true, "/api/public"), 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.Candidate, false, "/api/candidate"),
new(FeatureArea.Administration, false, "/api/admin"), new(FeatureArea.Administration, false, "/api/admin"),
new(FeatureArea.Admission, false, "/api/admission"), new(FeatureArea.Admission, false, "/api/admission"),
@@ -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; }
}
}
+16 -2
View File
@@ -1,10 +1,12 @@
using System.Net; using System.Net;
using Eis.Infrastructure.Authentication;
using Eis.Application.Public; using Eis.Application.Public;
using Eis.Infrastructure; using Eis.Infrastructure;
using Eis.Infrastructure.Data; using Eis.Infrastructure.Data;
using Eis.Infrastructure.Migration; using Eis.Infrastructure.Migration;
using Eis.Infrastructure.Security; using Eis.Infrastructure.Security;
using Eis.Web.Configuration; using Eis.Web.Configuration;
using Eis.Web.Authentication;
using Eis.Web.Frontend; using Eis.Web.Frontend;
using Eis.Web.Legacy; using Eis.Web.Legacy;
using Eis.Web.Public; using Eis.Web.Public;
@@ -29,11 +31,16 @@ builder.Services.AddHttpClient<LegacyApiProxy>((services, client) =>
}); });
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddSingleton<IPublicSiteConfiguration, PublicSiteConfiguration>(); builder.Services.AddSingleton<IPublicSiteConfiguration, PublicSiteConfiguration>();
var authenticationOptions = AuthenticationOptions.FromEnvironment(
builder.Environment.IsProduction(),
builder.Configuration.GetValue<bool>("AuthenticationMigration:NativeEnabled"));
builder.Services.AddEisInfrastructure( builder.Services.AddEisInfrastructure(
DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()), DatabaseOptions.FromEnvironment(applicationRoot, builder.Environment.IsProduction()),
DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction())); DocumentVerificationOptions.FromEnvironment(builder.Environment.IsProduction()),
authenticationOptions);
var app = builder.Build(); var app = builder.Build();
app.Services.EnsureNativeAuthenticationReady(authenticationOptions);
app.UseExceptionHandler(); app.UseExceptionHandler();
app.Use(async (context, next) => app.Use(async (context, next) =>
@@ -59,11 +66,18 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
{ {
status = legacyAvailable ? "healthy" : "degraded", status = legacyAvailable ? "healthy" : "degraded",
legacyApiAvailable = legacyAvailable, 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); }, statusCode: statusCode);
}); });
app.MapNativePublicEndpoints(); app.MapNativePublicEndpoints();
app.MapNativeAuthenticationEndpoints(authenticationOptions);
string[] methods = string[] methods =
[ [
+3
View File
@@ -3,6 +3,9 @@
"Enabled": true, "Enabled": true,
"BaseUrl": "http://127.0.0.1:4174" "BaseUrl": "http://127.0.0.1:4174"
}, },
"AuthenticationMigration": {
"NativeEnabled": false
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@@ -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);
}
}
@@ -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));
}
}
@@ -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));
}
}
+5
View File
@@ -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)));
@@ -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();