2026-07-08 20:05:38 +09:00

274 lines
10 KiB
C#

using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Admin;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
using OpenIddict.EntityFrameworkCore.Models;
namespace MemberCenter.Infrastructure.Services;
public sealed class AccountGovernanceService : IAccountGovernanceService
{
private const string AdminRole = "admin";
private const string SuperuserRole = "superuser";
private readonly MemberCenterDbContext _dbContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly IAuditLogWriter _auditLogWriter;
public AccountGovernanceService(
MemberCenterDbContext dbContext,
UserManager<ApplicationUser> userManager,
RoleManager<ApplicationRole> roleManager,
IAuditLogWriter auditLogWriter)
{
_dbContext = dbContext;
_userManager = userManager;
_roleManager = roleManager;
_auditLogWriter = auditLogWriter;
}
public async Task<IReadOnlyList<UserGovernanceSummaryDto>> ListUsersAsync(string? search = null, int take = 200)
{
var query = _dbContext.Users
.AsNoTracking()
.GroupJoin(
_dbContext.UserProfiles.AsNoTracking(),
user => user.Id,
profile => profile.UserId,
(user, profiles) => new
{
User = user,
Profile = profiles.FirstOrDefault()
});
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim().ToLower();
query = query.Where(x =>
(x.User.Email ?? string.Empty).ToLower().Contains(term)
|| (x.Profile != null && (
x.Profile.LastName.ToLower().Contains(term)
|| x.Profile.FirstName.ToLower().Contains(term)
|| (x.Profile.NickName != null && x.Profile.NickName.ToLower().Contains(term)))));
}
var users = await query
.OrderByDescending(x => x.User.CreatedAt)
.Take(Math.Clamp(take, 1, 500))
.ToListAsync();
var result = new List<UserGovernanceSummaryDto>(users.Count);
foreach (var item in users)
{
var roles = await _userManager.GetRolesAsync(item.User);
result.Add(new UserGovernanceSummaryDto(
item.User.Id,
item.User.Email ?? string.Empty,
item.Profile?.LastName,
item.Profile?.FirstName,
item.Profile?.NickName,
item.User.EmailConfirmed,
roles.Contains(AdminRole, StringComparer.OrdinalIgnoreCase),
roles.Contains(SuperuserRole, StringComparer.OrdinalIgnoreCase),
item.User.DisabledAt.HasValue,
item.User.IsBlacklisted,
item.User.CreatedAt,
item.User.LastLoginAt,
item.User.LastSeenAt,
item.User.DisabledAt,
item.User.DisabledBy));
}
return result;
}
public async Task SetAdminAsync(Guid actorUserId, Guid targetUserId, bool enabled)
{
await EnsureSuperuserAsync(actorUserId);
await EnsureRoleExistsAsync(AdminRole);
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
?? throw new InvalidOperationException("Target user not found.");
await EnsureTargetIsMutableAsync(targetUser);
if (enabled && targetUser.DisabledAt.HasValue)
{
throw new InvalidOperationException("Disabled accounts cannot be granted admin permissions.");
}
var inRole = await _userManager.IsInRoleAsync(targetUser, AdminRole);
if (enabled && !inRole)
{
EnsureSucceeded(await _userManager.AddToRoleAsync(targetUser, AdminRole));
}
else if (!enabled && inRole)
{
EnsureSucceeded(await _userManager.RemoveFromRoleAsync(targetUser, AdminRole));
}
await _auditLogWriter.WriteAsync("user", actorUserId, "account.role_changed", new
{
target_user_id = targetUser.Id,
email = targetUser.Email,
role = AdminRole,
enabled
});
}
public async Task SetDisabledAsync(Guid actorUserId, Guid targetUserId, bool disabled)
{
await EnsureAdminAsync(actorUserId);
if (actorUserId == targetUserId)
{
throw new InvalidOperationException("You cannot disable your own account.");
}
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
?? throw new InvalidOperationException("Target user not found.");
await EnsureTargetIsMutableAsync(targetUser);
await EnsureTargetIsMemberAsync(targetUser);
targetUser.DisabledAt = disabled ? DateTimeOffset.UtcNow : null;
targetUser.DisabledBy = disabled ? actorUserId.ToString() : null;
EnsureSucceeded(await _userManager.UpdateAsync(targetUser));
await _auditLogWriter.WriteAsync("user", actorUserId, disabled ? "account.disabled" : "account.enabled", new
{
target_user_id = targetUser.Id,
email = targetUser.Email
});
}
public async Task ResetPasswordAsync(Guid actorUserId, Guid targetUserId, string newPassword)
{
var actorIsSuperuser = await EnsureAdminAsync(actorUserId);
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
?? throw new InvalidOperationException("Target user not found.");
await EnsureTargetIsMutableAsync(targetUser);
var targetIsAdmin = await _userManager.IsInRoleAsync(targetUser, AdminRole);
if (targetIsAdmin && !actorIsSuperuser)
{
throw new InvalidOperationException("Only superuser can reset admin passwords.");
}
if (string.IsNullOrWhiteSpace(newPassword))
{
throw new InvalidOperationException("New password is required.");
}
var resetToken = await _userManager.GeneratePasswordResetTokenAsync(targetUser);
EnsureSucceeded(await _userManager.ResetPasswordAsync(targetUser, resetToken, newPassword));
EnsureSucceeded(await _userManager.UpdateSecurityStampAsync(targetUser));
await RevokeUserAuthorizationsAsync(targetUser.Id);
await _auditLogWriter.WriteAsync("user", actorUserId, "account.password_reset_by_admin", new
{
target_user_id = targetUser.Id,
email = targetUser.Email,
actor_is_superuser = actorIsSuperuser,
revoke_existing_sessions = true
});
}
private async Task EnsureSuperuserAsync(Guid actorUserId)
{
await EnsureRoleExistsAsync(SuperuserRole);
var actor = await _userManager.FindByIdAsync(actorUserId.ToString())
?? throw new InvalidOperationException("Actor user not found.");
if (!await _userManager.IsInRoleAsync(actor, SuperuserRole))
{
throw new InvalidOperationException("Only superuser can modify account governance.");
}
}
private async Task<bool> EnsureAdminAsync(Guid actorUserId)
{
await EnsureRoleExistsAsync(AdminRole);
await EnsureRoleExistsAsync(SuperuserRole);
var actor = await _userManager.FindByIdAsync(actorUserId.ToString())
?? throw new InvalidOperationException("Actor user not found.");
var isSuperuser = await _userManager.IsInRoleAsync(actor, SuperuserRole);
var isAdmin = await _userManager.IsInRoleAsync(actor, AdminRole);
if (!isAdmin && !isSuperuser)
{
throw new InvalidOperationException("Only admin can modify account governance.");
}
return isSuperuser;
}
private async Task EnsureTargetIsMutableAsync(ApplicationUser targetUser)
{
if (await _userManager.IsInRoleAsync(targetUser, SuperuserRole))
{
throw new InvalidOperationException("Superuser accounts cannot be modified from the management UI.");
}
}
private async Task EnsureTargetIsMemberAsync(ApplicationUser targetUser)
{
if (await _userManager.IsInRoleAsync(targetUser, AdminRole))
{
throw new InvalidOperationException("Admin accounts must have admin permissions removed before they can be disabled.");
}
}
private async Task EnsureRoleExistsAsync(string roleName)
{
if (await _roleManager.RoleExistsAsync(roleName))
{
return;
}
EnsureSucceeded(await _roleManager.CreateAsync(new ApplicationRole
{
Id = Guid.NewGuid(),
Name = roleName,
NormalizedName = roleName.ToUpperInvariant()
}));
}
private static void EnsureSucceeded(IdentityResult result)
{
if (result.Succeeded)
{
return;
}
throw new InvalidOperationException(string.Join("; ", result.Errors.Select(x => x.Description)));
}
private async Task RevokeUserAuthorizationsAsync(Guid userId)
{
var subject = userId.ToString();
var tokens = await _dbContext.Set<OpenIddictEntityFrameworkCoreToken>()
.Where(x => x.Subject == subject && x.Status != OpenIddictConstants.Statuses.Revoked)
.ToListAsync();
foreach (var token in tokens)
{
token.Status = OpenIddictConstants.Statuses.Revoked;
token.RedemptionDate = DateTime.UtcNow;
}
var authorizations = await _dbContext.Set<OpenIddictEntityFrameworkCoreAuthorization>()
.Where(x => x.Subject == subject && x.Status != OpenIddictConstants.Statuses.Revoked)
.ToListAsync();
foreach (var authorization in authorizations)
{
authorization.Status = OpenIddictConstants.Statuses.Revoked;
}
await _dbContext.SaveChangesAsync();
}
}