Add transfer tracking and secure admin access
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AdminAuth(AdminRoles.SuperAdmin)]
|
||||
[Route("api/admin/security")]
|
||||
public sealed class AdminAccountsController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("accounts")]
|
||||
public async Task<IActionResult> Accounts(CancellationToken ct)
|
||||
{
|
||||
var users = await db.AdminUsers.AsNoTracking()
|
||||
.OrderBy(item => item.Username)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.Username,
|
||||
item.Role,
|
||||
item.IsActive,
|
||||
item.MustChangePassword,
|
||||
item.LastLoginAt,
|
||||
item.CreatedAt,
|
||||
activeSessions = item.Sessions.Count(session =>
|
||||
!session.RevokedAt.HasValue && session.ExpiresAt > DateTime.UtcNow),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
[HttpPost("accounts")]
|
||||
public async Task<IActionResult> Create(CreateAdminUserRequest request, CancellationToken ct)
|
||||
{
|
||||
var username = request.Username.Trim();
|
||||
if (username.Length is < 3 or > 64 || request.Password.Length is < 12 or > 128 ||
|
||||
!AdminRoles.All.Contains(request.Role))
|
||||
return BadRequest(new ApiError("ADMIN_ACCOUNT_INVALID", "管理员账号、密码或角色无效"));
|
||||
if (await db.AdminUsers.AnyAsync(item => item.Username == username, ct))
|
||||
return Conflict(new ApiError("ADMIN_ACCOUNT_EXISTS", "管理员用户名已存在"));
|
||||
var now = DateTime.UtcNow;
|
||||
var user = new AdminUser
|
||||
{
|
||||
Username = username,
|
||||
PasswordHash = AdminSessionService.HashPassword(request.Password),
|
||||
Role = request.Role,
|
||||
IsActive = true,
|
||||
MustChangePassword = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
db.AdminUsers.Add(user);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { user.Id, user.Username, user.Role, user.IsActive, user.MustChangePassword });
|
||||
}
|
||||
|
||||
[HttpPut("accounts/{id:long}")]
|
||||
public async Task<IActionResult> Update(
|
||||
long id,
|
||||
UpdateAdminUserRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!AdminRoles.All.Contains(request.Role))
|
||||
return BadRequest(new ApiError("ADMIN_ROLE_INVALID", "管理员角色无效"));
|
||||
var user = await db.AdminUsers.FindAsync([id], ct);
|
||||
if (user is null) return NotFound();
|
||||
if (user.Role == AdminRoles.SuperAdmin &&
|
||||
(!request.IsActive || request.Role != AdminRoles.SuperAdmin) &&
|
||||
await ActiveSuperAdminCount(ct) <= 1)
|
||||
{
|
||||
return Conflict(new ApiError("LAST_SUPER_ADMIN", "不能停用或降级最后一个超级管理员"));
|
||||
}
|
||||
user.Role = request.Role;
|
||||
user.IsActive = request.IsActive;
|
||||
user.MustChangePassword = request.MustChangePassword;
|
||||
user.AuthVersion++;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await RevokeSessions(id, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Ok(new { user.Id, user.Username, user.Role, user.IsActive, user.MustChangePassword });
|
||||
}
|
||||
|
||||
[HttpPut("accounts/{id:long}/password")]
|
||||
public async Task<IActionResult> ResetPassword(
|
||||
long id,
|
||||
ResetAdminPasswordRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Password.Length is < 12 or > 128)
|
||||
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 12 到 128 位"));
|
||||
var user = await db.AdminUsers.FindAsync([id], ct);
|
||||
if (user is null) return NotFound();
|
||||
user.PasswordHash = AdminSessionService.HashPassword(request.Password);
|
||||
user.MustChangePassword = true;
|
||||
user.AuthVersion++;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await RevokeSessions(id, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("accounts/{id:long}/revoke-sessions")]
|
||||
public async Task<IActionResult> Revoke(long id, CancellationToken ct)
|
||||
{
|
||||
if (!await db.AdminUsers.AnyAsync(item => item.Id == id, ct)) return NotFound();
|
||||
await RevokeSessions(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("audit")]
|
||||
public async Task<IActionResult> Audit(
|
||||
[FromQuery] string? username,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int limit = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
var query = db.AdminAuditLogs.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
var term = username.Trim();
|
||||
query = query.Where(item => item.Username != null && item.Username.Contains(term));
|
||||
}
|
||||
var total = await query.CountAsync(ct);
|
||||
var list = await query.OrderByDescending(item => item.CreatedAt)
|
||||
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
||||
return Ok(new { total, page, list });
|
||||
}
|
||||
|
||||
private Task<int> ActiveSuperAdminCount(CancellationToken ct) => db.AdminUsers.CountAsync(
|
||||
item => item.IsActive && item.Role == AdminRoles.SuperAdmin,
|
||||
ct);
|
||||
|
||||
private Task<int> RevokeSessions(long userId, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return db.AdminSessions.Where(item => item.AdminUserId == userId && !item.RevokedAt.HasValue)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.RevokedAt, now), ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user