Files
jizhi/backend/MiaoJiZhang.Api/Services/AdminSessionService.cs
T

218 lines
8.3 KiB
C#

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<AdminLoginResult?> 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<string> 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<bool?>("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";
}