Add transfer tracking and secure admin access
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user