Files
EIS-dotnet/src/Eis.Infrastructure/Authentication/AuthenticationRepository.cs
T
biss 98d429f7de
Build and publish packages and Docker images / Test, build and publish (push) Successful in 43m50s
修改
2026-07-24 11:41:35 +08:00

590 lines
26 KiB
C#

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);
}