using System.Security.Cryptography; using System.Text; using MiaoJiZhang.Domain.Entities; using MiaoJiZhang.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MiaoJiZhang.Api.Services; public sealed record AdminPrincipal( long UserId, long SessionId, string Username, string Role, bool MustChangePassword); public sealed record AdminLoginResult(AdminPrincipal Principal, string CsrfToken); public static class AdminRequestContext { public const string PrincipalKey = "miaoji.admin.principal"; public const string AuditUsernameKey = "miaoji.admin.audit.username"; public static AdminPrincipal? Principal(HttpContext context) => context.Items.TryGetValue(PrincipalKey, out var value) ? value as AdminPrincipal : null; } public sealed class AdminSessionService( AppDbContext db, IConfiguration configuration, IWebHostEnvironment environment) { public const string CookieName = "miaoji_admin_session"; public const string CsrfHeader = "X-CSRF-Token"; private static readonly TimeSpan IdleLifetime = TimeSpan.FromHours(8); private static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(7); private static readonly TimeSpan LockoutLifetime = TimeSpan.FromMinutes(15); public async Task LoginAsync( HttpContext context, string username, string password, CancellationToken ct) { var normalizedUsername = username.Trim(); context.Items[AdminRequestContext.AuditUsernameKey] = normalizedUsername; var user = await db.AdminUsers.FirstOrDefaultAsync( item => item.Username == normalizedUsername, ct); var now = DateTime.UtcNow; if (user is null || !user.IsActive || user.LockedUntil.HasValue && user.LockedUntil.Value > now) { BCrypt.Net.BCrypt.Verify(password, DummyPasswordHash()); return null; } if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) { user.FailedLoginCount++; if (user.FailedLoginCount >= 5) { user.FailedLoginCount = 0; user.LockedUntil = now.Add(LockoutLifetime); } user.UpdatedAt = now; await db.SaveChangesAsync(ct); return null; } user.FailedLoginCount = 0; user.LockedUntil = null; user.LastLoginAt = now; user.UpdatedAt = now; var rawToken = NewToken(); var csrfToken = NewToken(); var session = new AdminSession { AdminUser = user, TokenHash = Hash(rawToken), CsrfTokenHash = Hash(csrfToken), AuthVersion = user.AuthVersion, ExpiresAt = now.Add(IdleLifetime), AbsoluteExpiresAt = now.Add(AbsoluteLifetime), LastSeenAt = now, IpAddress = ClientIp(context), UserAgent = Trim(context.Request.Headers.UserAgent.ToString(), 300), CreatedAt = now, }; db.AdminSessions.Add(session); await db.SaveChangesAsync(ct); WriteCookie(context, rawToken, session.AbsoluteExpiresAt); var principal = ToPrincipal(user, session); context.Items[AdminRequestContext.PrincipalKey] = principal; return new AdminLoginResult(principal, csrfToken); } public async Task<(AdminPrincipal Principal, string CsrfToken)?> AuthenticateAsync( HttpContext context, bool validateCsrf, CancellationToken ct) { if (!context.Request.Cookies.TryGetValue(CookieName, out var token) || string.IsNullOrWhiteSpace(token)) return null; var tokenHash = Hash(token); var now = DateTime.UtcNow; var session = await db.AdminSessions .Include(item => item.AdminUser) .FirstOrDefaultAsync(item => item.TokenHash == tokenHash, ct); if (session is null || session.RevokedAt.HasValue || session.ExpiresAt <= now || session.AbsoluteExpiresAt <= now || !session.AdminUser.IsActive || session.AuthVersion != session.AdminUser.AuthVersion) { DeleteCookie(context); return null; } var csrfToken = context.Request.Headers[CsrfHeader].FirstOrDefault(); if (validateCsrf && (string.IsNullOrWhiteSpace(csrfToken) || !CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(Hash(csrfToken)), Encoding.ASCII.GetBytes(session.CsrfTokenHash)))) { return null; } if (now - session.LastSeenAt >= TimeSpan.FromMinutes(5)) { session.LastSeenAt = now; session.ExpiresAt = Min(now.Add(IdleLifetime), session.AbsoluteExpiresAt); await db.SaveChangesAsync(ct); } var principal = ToPrincipal(session.AdminUser, session); context.Items[AdminRequestContext.PrincipalKey] = principal; return (principal, csrfToken ?? string.Empty); } public async Task RotateCsrfAsync(long sessionId, CancellationToken ct) { var session = await db.AdminSessions.FindAsync([sessionId], ct) ?? throw new InvalidOperationException("管理会话不存在"); var token = NewToken(); session.CsrfTokenHash = Hash(token); await db.SaveChangesAsync(ct); return token; } public async Task LogoutAsync(HttpContext context, long sessionId, CancellationToken ct) { var session = await db.AdminSessions.FindAsync([sessionId], ct); if (session is not null && !session.RevokedAt.HasValue) { session.RevokedAt = DateTime.UtcNow; await db.SaveChangesAsync(ct); } DeleteCookie(context); } public async Task RevokeOtherSessionsAsync( long userId, long currentSessionId, int authVersion, CancellationToken ct) { var now = DateTime.UtcNow; await db.AdminSessions .Where(item => item.AdminUserId == userId && item.Id != currentSessionId && !item.RevokedAt.HasValue) .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.RevokedAt, now), ct); var current = await db.AdminSessions.FindAsync([currentSessionId], ct); if (current is not null) current.AuthVersion = authVersion; } public static string HashPassword(string password) => BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12); public static bool IsSafeMethod(string method) => HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method); private void WriteCookie(HttpContext context, string token, DateTime expiresAt) => context.Response.Cookies.Append(CookieName, token, CookieOptions(context, expiresAt)); private void DeleteCookie(HttpContext context) => context.Response.Cookies.Delete(CookieName, CookieOptions(context, DateTime.UtcNow.AddDays(-1))); private CookieOptions CookieOptions(HttpContext context, DateTime expiresAt) => new() { HttpOnly = true, Secure = configuration.GetValue("Admin:CookieSecure") ?? (!environment.IsDevelopment() || context.Request.IsHttps), SameSite = SameSiteMode.Strict, Path = "/api/admin", IsEssential = true, Expires = expiresAt, }; private static AdminPrincipal ToPrincipal(AdminUser user, AdminSession session) => new(user.Id, session.Id, user.Username, user.Role, user.MustChangePassword); private static DateTime Min(DateTime left, DateTime right) => left <= right ? left : right; private static string NewToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) .TrimEnd('=').Replace('+', '-').Replace('/', '_'); private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); private static string? ClientIp(HttpContext context) => Trim(context.Connection.RemoteIpAddress?.ToString(), 64); private static string? Trim(string? value, int length) => string.IsNullOrEmpty(value) ? null : value.Length <= length ? value : value[..length]; private static string DummyPasswordHash() => "$2a$12$1i3L4fD4PrM9xMVzKDnwoO.nGsRoW6u9Q9tT6A4vPi04QoV9S3Mca"; }