Files

102 lines
10 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "管理员密码长度应为 12128 位"); }
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<AdminAccount> 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<AdminAccount> 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<AdminAccount> 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<AdminAccount> 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, "邮箱格式不正确");
}
}