218 lines
8.3 KiB
C#
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";
|
|
}
|