- Implemented NotifyUsersNewsPublishedAsync method in TelegramNotifier to send notifications to activated Telegram users when a news post is published. - Updated CreatePostCommandHandler to invoke the new Telegram notification method after creating a news post. - Enhanced IIdentityService to retrieve activated linked Telegram user IDs for notifications. - Updated ITelegramNotifier interface to include the new notification method documentation.
278 lines
12 KiB
C#
278 lines
12 KiB
C#
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<AppUser> userManager, SignInManager<AppUser> signInManager, RoleManager<AppRole> roleManager)
|
||
: IIdentityService
|
||
{
|
||
public async Task<Result<Guid>> 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<Guid>(AuthErrors.DuplicateUserName)
|
||
: Result.Failure<Guid>(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<Result<AuthenticatedUser>> ValidateCredentialsAsync(string userName, string password, CancellationToken cancellationToken)
|
||
{
|
||
var user = await userManager.FindByNameAsync(userName);
|
||
if (user is null)
|
||
return Result.Failure<AuthenticatedUser>(AuthErrors.InvalidCredentials);
|
||
|
||
if (user.IsBlocked)
|
||
return Result.Failure<AuthenticatedUser>(AuthErrors.UserBlocked);
|
||
|
||
var checkResult = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true);
|
||
if (checkResult.IsLockedOut)
|
||
return Result.Failure<AuthenticatedUser>(AuthErrors.LockedOut);
|
||
if (!checkResult.Succeeded)
|
||
return Result.Failure<AuthenticatedUser>(AuthErrors.InvalidCredentials);
|
||
|
||
var roleName = await GetPrimaryRoleNameAsync(user);
|
||
return Result.Success(new AuthenticatedUser(user.Id, user.UserName!, roleName));
|
||
}
|
||
|
||
public async Task<CurrentUserProfile?> 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<Result> 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<Result> 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<Result> 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<IReadOnlyDictionary<Guid, string>> GetUserNamesAsync(IReadOnlyCollection<Guid> userIds, CancellationToken cancellationToken)
|
||
{
|
||
if (userIds.Count == 0)
|
||
return new Dictionary<Guid, string>();
|
||
|
||
return await userManager.Users
|
||
.Where(u => userIds.Contains(u.Id))
|
||
.ToDictionaryAsync(u => u.Id, u => u.UserName!, cancellationToken);
|
||
}
|
||
|
||
public async Task<Result> 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<Guid?> FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken)
|
||
{
|
||
var user = await userManager.Users.AsNoTracking()
|
||
.FirstOrDefaultAsync(u => u.SubscriptionToken == token, cancellationToken);
|
||
return user?.Id;
|
||
}
|
||
|
||
public async Task<Result> 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<Result> 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<Result> 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<PagedList<UserSummaryDto>> 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<UserSummaryDto>(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<UserSummaryDto>(items, total, page, pageSize);
|
||
}
|
||
|
||
public async Task<UserStatsDto> 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<Result> 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<Result> 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<Guid?> FindUserIdByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken)
|
||
{
|
||
var user = await userManager.Users.AsNoTracking()
|
||
.FirstOrDefaultAsync(u => u.TelegramUserId == telegramUserId, cancellationToken);
|
||
return user?.Id;
|
||
}
|
||
|
||
public async Task<TelegramLinkInfo> 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<IReadOnlyCollection<long>> 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<string> GetPrimaryRoleNameAsync(AppUser user)
|
||
{
|
||
var roles = await userManager.GetRolesAsync(user);
|
||
return roles.FirstOrDefault() ?? RoleNames.User;
|
||
}
|
||
|
||
private async Task<AppRole> 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));
|
||
}
|