using System.Security.Claims; using System.Security.Cryptography; using System.Text; using IM.Admin.Data; using IM.Admin.Services; using IM.InitCommon.Management; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace IM.Admin.Api; public record LoginInput(string Account, string Password); public record PasswordInput(string CurrentPassword, string NewPassword); public record ResetRequest(string Account); public record ResetInput(string Token, string Password); public record AccountInput(string Account, string Name, string Email, string Password, string Role, bool Enabled, string Reason); public static class AuthEndpoints { public static object Public(AdminAccount a) => new { a.Id, a.Account, a.Name, a.Email, a.Role, status = a.Enabled ? "启用" : "停用", a.CreatedAt }; public static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); public static void CheckPassword(string p) { if (p.Length is < 12 or > 128) throw new ApiError(400, "管理员密码长度应为 12–128 位"); } public static void MapAuth(this WebApplication app) { var api = app.MapGroup("/api/admin/auth"); api.MapGet("/csrf", (HttpContext c, IAntiforgery af) => ManagementResult.Ok(new { token = af.GetAndStoreTokens(c).RequestToken })); api.MapPost("/login", async (LoginInput input, AdminDb db, IPasswordHasher hasher, SettingsService settings, HttpContext c) => { if (string.IsNullOrWhiteSpace(input.Account) || input.Account.Length > 100 || string.IsNullOrEmpty(input.Password) || input.Password.Length > 128) throw new ApiError(400, "账号或密码格式不正确"); var name = input.Account.Trim().ToLowerInvariant(); await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable); var a = await db.Accounts.FromSqlInterpolated($"SELECT * FROM admin_accounts WHERE Account = {name} FOR UPDATE").SingleOrDefaultAsync(); var policy = await settings.Policy(); if (a is null || !a.Enabled || a.LockedUntil > DateTime.UtcNow) throw new ApiError(401, "账号或密码错误,或账号暂不可用"); if (hasher.VerifyHashedPassword(a, a.PasswordHash, input.Password) == PasswordVerificationResult.Failed) { a.FailedAttempts++; if (a.FailedAttempts >= policy.AdminLockThreshold) a.LockedUntil = DateTime.UtcNow.AddMinutes(policy.AdminLockMinutes); db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "后台登录", TargetId = a.Id.ToString(), Result = "失败", Reason = "密码验证未通过", After = a.LockedUntil.HasValue ? "临时锁定" : "登录失败" }); await db.SaveChangesAsync(); await tx.CommitAsync(); throw new ApiError(401, "账号或密码错误,或账号暂不可用"); } a.FailedAttempts = 0; a.LockedUntil = null; db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "后台登录", TargetId = a.Id.ToString(), Reason = "独立管理员登录", After = "登录成功" }); await db.SaveChangesAsync(); await tx.CommitAsync(); var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, a.Id.ToString()), new Claim(ClaimTypes.Name, a.Name), new Claim(ClaimTypes.Role, a.Role), new Claim("stamp", a.Stamp)], "Admin")); await c.SignInAsync("Admin", principal, new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(policy.AdminSessionMinutes) }); return ManagementResult.Ok(Public(a)); }).RequireRateLimiting("login"); api.MapGet("/me", async (HttpContext c, AdminDb db) => ManagementResult.Ok(Public(await db.Accounts.SingleAsync(x => x.Id == c.Actor())))).RequireAuthorization(); api.MapPost("/logout", async (HttpContext c) => { await c.SignOutAsync("Admin"); return ManagementResult.Ok(); }).RequireAuthorization(); api.MapPost("/password", async (PasswordInput input, HttpContext c, AdminDb db, IPasswordHasher hasher) => { CheckPassword(input.NewPassword); var a = await db.Accounts.SingleAsync(x => x.Id == c.Actor()); if (hasher.VerifyHashedPassword(a, a.PasswordHash, input.CurrentPassword) == PasswordVerificationResult.Failed) throw new ApiError(400, "当前密码不正确"); a.PasswordHash = hasher.HashPassword(a, input.NewPassword); a.Stamp = Guid.NewGuid().ToString("N"); db.Audit(c, "修改密码", a.Id.ToString(), "", "已更新", "管理员修改本人密码"); await db.SaveChangesAsync(); await c.SignOutAsync("Admin"); return ManagementResult.Ok(); }).RequireAuthorization(); api.MapPost("/forgot", async (ResetRequest input, AdminDb db, InfrastructureService mail, IConfiguration config) => { var a = await db.Accounts.SingleOrDefaultAsync(x => x.Account == input.Account.Trim().ToLowerInvariant() && x.Enabled); if (a is not null && !string.IsNullOrWhiteSpace(a.Email) && await mail.MailEnabled()) { var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); db.Resets.Add(new PasswordReset { Id = Hash(token), AccountId = a.Id, ExpiresAt = DateTime.UtcNow.AddMinutes(20) }); await db.SaveChangesAsync(); var origin = config["Management:AdminPublicUrl"] ?? throw new ApiError(503, "未配置后台访问地址"); await mail.Send(a.Email, "IM 后台密码重置", $"请在 20 分钟内打开以下地址重置密码:{origin.TrimEnd('/')}/#/reset-password?token={token}\n如果不是你发起的请求,请忽略此邮件。"); } return ManagementResult.Ok(new { message = "如果账号可用且已配置邮件,将收到重置说明。" }); }).RequireRateLimiting("login"); api.MapPost("/reset-password", async (ResetInput input, AdminDb db, IPasswordHasher hasher) => { CheckPassword(input.Password); await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable); var reset = await db.Resets.SingleOrDefaultAsync(x => x.Id == Hash(input.Token) && x.ExpiresAt > DateTime.UtcNow); if (reset is null) throw new ApiError(400, "链接无效或已过期"); var a = await db.Accounts.SingleAsync(x => x.Id == reset.AccountId); if (!a.Enabled) throw new ApiError(400, "账号已停用"); a.PasswordHash = hasher.HashPassword(a, input.Password); a.Stamp = Guid.NewGuid().ToString("N"); a.FailedAttempts = 0; a.LockedUntil = null; db.Resets.RemoveRange(await db.Resets.Where(x => x.AccountId == a.Id).ToListAsync()); db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "重置密码", TargetId = a.Id.ToString(), After = "已更新", Reason = "通过邮件重置密码" }); await db.SaveChangesAsync(); await tx.CommitAsync(); return ManagementResult.Ok(); }).RequireRateLimiting("login"); var accounts = app.MapGroup("/api/admin/admins").RequireAuthorization("super"); accounts.MapGet("", async (AdminDb db, string? q, string? status, string? role, int? page, int? size, CancellationToken ct) => { var query = db.Accounts.AsNoTracking().Where(x => q == null || x.Account.Contains(q) || x.Name.Contains(q)); if (!string.IsNullOrEmpty(status)) query = query.Where(x => x.Enabled == (status == "启用")); if (!string.IsNullOrEmpty(role)) query = query.Where(x => x.Role == role); var result = await ApiSupport.Page(query.OrderBy(x => x.CreatedAt).Select(x => new { x.Id, x.Name, x.Account, x.Email, x.Role, status = x.Enabled ? "启用" : "停用", x.CreatedAt }), page ?? 1, size ?? 8, ct); return ManagementResult.Ok(result); }); accounts.MapPost("", async (AccountInput input, AdminDb db, HttpContext c, IPasswordHasher hasher) => { Validate(input); CheckPassword(input.Password); var account = input.Account.Trim().ToLowerInvariant(); if (await db.Accounts.AnyAsync(x => x.Account == account)) throw new ApiError(409, "管理员账号已存在"); var a = new AdminAccount { Account = account, Name = input.Name.Trim(), Email = input.Email.Trim(), Role = input.Role, Enabled = input.Enabled }; a.PasswordHash = hasher.HashPassword(a, input.Password); db.Accounts.Add(a); db.Audit(c, "创建管理员", a.Id.ToString(), "", input.Role, input.Reason); await db.SaveChangesAsync(); return ManagementResult.Ok(Public(a)); }); accounts.MapPut("/{id:guid}", async (Guid id, AccountInput input, AdminDb db, HttpContext c) => { Validate(input); await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable); var a = await db.Accounts.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "管理员不存在"); if (a.Id == c.Actor() && (input.Role != a.Role || !input.Enabled)) throw new ApiError(400, "不能停用或变更自己的角色"); if (a.Role == "super" && a.Enabled && (input.Role != "super" || !input.Enabled) && await db.Accounts.CountAsync(x => x.Role == "super" && x.Enabled) <= 1) throw new ApiError(400, "必须保留一名启用的超级管理员"); var before = $"{a.Role}/{a.Enabled}"; a.Name = input.Name.Trim(); a.Email = input.Email.Trim(); a.Role = input.Role; a.Enabled = input.Enabled; a.Stamp = Guid.NewGuid().ToString("N"); db.Audit(c, "修改管理员", id.ToString(), before, $"{a.Role}/{a.Enabled}", input.Reason); await db.SaveChangesAsync(); await tx.CommitAsync(); return ManagementResult.Ok(Public(a)); }); } static void Validate(AccountInput i) { ApiSupport.Reason(i.Reason); if (i.Account.Length is < 3 or > 100 || string.IsNullOrWhiteSpace(i.Name) || i.Name.Length > 50 || !new[] { "super", "operator", "reviewer" }.Contains(i.Role)) throw new ApiError(400, "管理员资料不正确"); if (!string.IsNullOrEmpty(i.Email) && !System.Net.Mail.MailAddress.TryCreate(i.Email, out _)) throw new ApiError(400, "邮箱格式不正确"); } }