自主注册、登录、退出、当前用户与密码修改
兼容现有 PBKDF2 密码和 hz_session Cookie TOTP 绑定、二步登录、防重放、恢复码与 AES-GCM 密钥 与 Node 完全一致的 Redis 键格式,可跨运行时共享会话 生产环境开启原生认证时强制要求 Redis 默认保持兼容代理;设置 AUTH_NATIVE_ENABLED=true 即可切换
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user