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

兼容现有 PBKDF2 密码和 hz_session Cookie
TOTP 绑定、二步登录、防重放、恢复码与 AES-GCM 密钥
与 Node 完全一致的 Redis 键格式,可跨运行时共享会话
生产环境开启原生认证时强制要求 Redis
默认保持兼容代理;设置 AUTH_NATIVE_ENABLED=true 即可切换
This commit is contained in:
2026-07-22 19:34:11 +08:00 Unverified
parent 017cacc6f9
commit ae472aabb0
26 changed files with 2569 additions and 21 deletions
@@ -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
+23 -1
View File
@@ -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"),
@@ -168,20 +168,20 @@ public sealed partial class PublicQueryService
AdmissionRecordRow item,
IReadOnlyDictionary<string, string> examNames,
IReadOnlyDictionary<string, string> schoolNames) => new()
{
["id"] = item.Id,
["examId"] = item.ExamId,
["examName"] = examNames.GetValueOrDefault(item.ExamId) ?? string.Empty,
["schoolName"] = item.SchoolId is null ? string.Empty : schoolNames.GetValueOrDefault(item.SchoolId) ?? string.Empty,
["publishedAt"] = GetString(item.Payload, "publishedAt") ?? item.UpdatedAt,
["rows"] = new JsonArray(GetArray(item.Payload, "rows").OfType<JsonObject>().Select(row => new JsonObject
{
["registrationNumber"] = GetString(row, "registrationNumber") ?? string.Empty,
["name"] = GetString(row, "name") ?? string.Empty,
["eligible"] = GetBoolean(row, "eligible", false),
["specialtyLabel"] = GetString(row, "specialtyLabel") ?? "普通生"
}).ToArray())
};
["id"] = item.Id,
["examId"] = item.ExamId,
["examName"] = examNames.GetValueOrDefault(item.ExamId) ?? string.Empty,
["schoolName"] = item.SchoolId is null ? string.Empty : schoolNames.GetValueOrDefault(item.SchoolId) ?? string.Empty,
["publishedAt"] = GetString(item.Payload, "publishedAt") ?? item.UpdatedAt,
["rows"] = new JsonArray(GetArray(item.Payload, "rows").OfType<JsonObject>().Select(row => new JsonObject
{
["registrationNumber"] = GetString(row, "registrationNumber") ?? string.Empty,
["name"] = GetString(row, "name") ?? string.Empty,
["eligible"] = GetBoolean(row, "eligible", false),
["specialtyLabel"] = GetString(row, "specialtyLabel") ?? "普通生"
}).ToArray())
};
private static bool QualificationComplete(
AdmissionRecordRow publication,