自主注册、登录、退出、当前用户与密码修改
兼容现有 PBKDF2 密码和 hz_session Cookie TOTP 绑定、二步登录、防重放、恢复码与 AES-GCM 密钥 与 Node 完全一致的 Redis 键格式,可跨运行时共享会话 生产环境开启原生认证时强制要求 Redis 默认保持兼容代理;设置 AUTH_NATIVE_ENABLED=true 即可切换
This commit is contained in:
@@ -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!
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<PackageVersion Include="AngleSharp" Version="1.5.2" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
||||
<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="StackExchange.Redis" Version="3.0.17" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
{
|
||||
DataSource = options.SqlitePath,
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
Mode = SqliteOpenMode.ReadWrite,
|
||||
Cache = SqliteCacheMode.Shared,
|
||||
ForeignKeys = true,
|
||||
DefaultTimeout = 5
|
||||
|
||||
@@ -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<IAuthenticationStateStore>();
|
||||
}
|
||||
}
|
||||
|
||||
public static IServiceCollection AddEisInfrastructure(
|
||||
this IServiceCollection services,
|
||||
DatabaseOptions databaseOptions,
|
||||
DocumentVerificationOptions documentVerificationOptions)
|
||||
DocumentVerificationOptions documentVerificationOptions,
|
||||
AuthenticationOptions authenticationOptions)
|
||||
{
|
||||
services.AddSingleton(databaseOptions);
|
||||
services.AddSingleton<IRelationalConnectionFactory, RelationalConnectionFactory>();
|
||||
services.AddSingleton(documentVerificationOptions);
|
||||
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>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="MySqlConnector" />
|
||||
<PackageReference Include="QRCoder" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,10 +5,10 @@ namespace Eis.Infrastructure.Migration;
|
||||
|
||||
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.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"),
|
||||
|
||||
@@ -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
@@ -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<LegacyApiProxy>((services, client) =>
|
||||
});
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddSingleton<IPublicSiteConfiguration, PublicSiteConfiguration>();
|
||||
var authenticationOptions = AuthenticationOptions.FromEnvironment(
|
||||
builder.Environment.IsProduction(),
|
||||
builder.Configuration.GetValue<bool>("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 =
|
||||
[
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"Enabled": true,
|
||||
"BaseUrl": "http://127.0.0.1:4174"
|
||||
},
|
||||
"AuthenticationMigration": {
|
||||
"NativeEnabled": false
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user