274 lines
10 KiB
C#
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();
|
|
}
|
|
}
|