using System.Security.Cryptography; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Models; namespace PnvPanel.Infrastructure.Identity; internal sealed class IdentityService(UserManager userManager, SignInManager signInManager, RoleManager roleManager) : IIdentityService { public async Task> CreateUserAsync(string userName, string password, CancellationToken cancellationToken) { var user = new AppUser { UserName = userName, IsActivated = false, SubscriptionToken = GenerateSubscriptionToken(), }; var createResult = await userManager.CreateAsync(user, password); if (!createResult.Succeeded) { return createResult.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName)) ? Result.Failure(AuthErrors.DuplicateUserName) : Result.Failure(Error.Validation( "Auth.RegistrationFailed", string.Join("; ", createResult.Errors.Select(e => e.Description)))); } await userManager.AddToRoleAsync(user, RoleNames.User); return Result.Success(user.Id); } public async Task> ValidateCredentialsAsync(string userName, string password, CancellationToken cancellationToken) { var user = await userManager.FindByNameAsync(userName); if (user is null) return Result.Failure(AuthErrors.InvalidCredentials); if (user.IsBlocked) return Result.Failure(AuthErrors.UserBlocked); var checkResult = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true); if (checkResult.IsLockedOut) return Result.Failure(AuthErrors.LockedOut); if (!checkResult.Succeeded) return Result.Failure(AuthErrors.InvalidCredentials); var roleName = await GetPrimaryRoleNameAsync(user); return Result.Success(new AuthenticatedUser(user.Id, user.UserName!, roleName)); } public async Task GetProfileAsync(Guid userId, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) return null; var role = await GetPrimaryRoleAsync(user); return new CurrentUserProfile( user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs, role.MaxIpLimit, user.SubscriptionToken); } public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) return Result.Failure(AuthErrors.Unauthorized); var result = await userManager.ChangePasswordAsync(user, currentPassword, newPassword); return result.Succeeded ? Result.Success() : Result.Failure(Error.Validation( "Auth.PasswordChangeFailed", string.Join("; ", result.Errors.Select(e => e.Description)))); } public async Task ChangeUserNameAsync(Guid userId, string newUserName, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) return Result.Failure(AuthErrors.Unauthorized); var result = await userManager.SetUserNameAsync(user, newUserName); if (result.Succeeded) return Result.Success(); return result.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName)) ? Result.Failure(AuthErrors.DuplicateUserName) : Result.Failure(Error.Validation( "Auth.UserNameChangeFailed", string.Join("; ", result.Errors.Select(e => e.Description)))); } public async Task ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) return Result.Failure(AuthErrors.Unauthorized); // Мутируем уже отслеживаемую EF-сущность напрямую, без UserManager.UpdateAsync (который // закоммитил бы немедленно) — изменение попадёт в общий SaveChanges вместе с ActivationRequest. user.IsActivated = true; user.ActivatedAt = DateTimeOffset.UtcNow; user.ActivatedBy = activatedBy; return Result.Success(); } public async Task> GetUserNamesAsync(IReadOnlyCollection userIds, CancellationToken cancellationToken) { if (userIds.Count == 0) return new Dictionary(); return await userManager.Users .Where(u => userIds.Contains(u.Id)) .ToDictionaryAsync(u => u.Id, u => u.UserName!, cancellationToken); } public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) return Result.Failure(AuthErrors.Unauthorized); var result = await userManager.DeleteAsync(user); return result.Succeeded ? Result.Success() : Result.Failure(Error.Failure( "Auth.DeleteAccountFailed", string.Join("; ", result.Errors.Select(e => e.Description)))); } public async Task FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken) { var user = await userManager.Users.AsNoTracking() .FirstOrDefaultAsync(u => u.SubscriptionToken == token, cancellationToken); return user?.Id; } public async Task BlockUserAsync(Guid userId, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) return Result.Failure(AuthErrors.Unauthorized); user.IsBlocked = true; await userManager.UpdateSecurityStampAsync(user); // гасит уже выданные refresh-токены де-факто — токен привязан к пользователю, не к stamp; отзыв делает вызывающий handler явно return Result.Success(); } public async Task UnblockUserAsync(Guid userId, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) return Result.Failure(AuthErrors.Unauthorized); user.IsBlocked = false; return Result.Success(); } public async Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) return Result.Failure(AuthErrors.Unauthorized); var token = await userManager.GeneratePasswordResetTokenAsync(user); var result = await userManager.ResetPasswordAsync(user, token, newPassword); return result.Succeeded ? Result.Success() : Result.Failure(Error.Validation( "Auth.PasswordResetFailed", string.Join("; ", result.Errors.Select(e => e.Description)))); } public async Task> ListUsersAsync(int page, int pageSize, string? search, CancellationToken cancellationToken) { var query = userManager.Users.AsNoTracking(); if (!string.IsNullOrWhiteSpace(search)) query = query.Where(u => u.UserName!.Contains(search)); var total = await query.CountAsync(cancellationToken); var users = await query .OrderBy(u => u.UserName) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); var items = new List(users.Count); foreach (var user in users) { var roleName = await GetPrimaryRoleNameAsync(user); items.Add(new UserSummaryDto(user.Id, user.UserName!, roleName, user.IsActivated, user.IsBlocked, user.ActivatedAt)); } return new PagedList(items, total, page, pageSize); } public async Task GetUserStatsAsync(CancellationToken cancellationToken) { var total = await userManager.Users.CountAsync(cancellationToken); var activated = await userManager.Users.CountAsync(u => u.IsActivated, cancellationToken); return new UserStatsDto(total, activated); } public async Task LinkTelegramAsync(Guid userId, long telegramUserId, string? telegramUsername, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) return Result.Failure(AuthErrors.Unauthorized); var alreadyLinked = await userManager.Users.AsNoTracking() .AnyAsync(u => u.TelegramUserId == telegramUserId && u.Id != userId, cancellationToken); if (alreadyLinked) return Result.Failure(Error.Conflict("Telegram.AlreadyLinked", "Этот Telegram уже привязан к другому аккаунту.")); user.TelegramUserId = telegramUserId; user.TelegramUsername = telegramUsername; user.TelegramLinkedAt = DateTimeOffset.UtcNow; return Result.Success(); } public async Task UnlinkTelegramAsync(Guid userId, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) return Result.Failure(AuthErrors.Unauthorized); user.TelegramUserId = null; user.TelegramUsername = null; user.TelegramLinkedAt = null; return Result.Success(); } public async Task FindUserIdByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken) { var user = await userManager.Users.AsNoTracking() .FirstOrDefaultAsync(u => u.TelegramUserId == telegramUserId, cancellationToken); return user?.Id; } public async Task GetTelegramLinkInfoAsync(Guid userId, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); return user?.TelegramUserId is not null ? new TelegramLinkInfo(true, user.TelegramUserId, user.TelegramUsername) : new TelegramLinkInfo(false, null, null); } public async Task> GetActivatedLinkedTelegramUserIdsAsync(CancellationToken cancellationToken) { return await userManager.Users.AsNoTracking() .Where(u => u.TelegramUserId != null && u.IsActivated && !u.IsBlocked) .Select(u => u.TelegramUserId!.Value) .ToListAsync(cancellationToken); } private async Task GetPrimaryRoleNameAsync(AppUser user) { var roles = await userManager.GetRolesAsync(user); return roles.FirstOrDefault() ?? RoleNames.User; } private async Task GetPrimaryRoleAsync(AppUser user) { var roleName = await GetPrimaryRoleNameAsync(user); return await roleManager.FindByNameAsync(roleName) ?? throw new InvalidOperationException($"Роль '{roleName}' не найдена."); } private static string GenerateSubscriptionToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); }