Files
PnvPanel/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs
T
Leonid Pershin a833d9aa5b
CI / Backend (build + test) (push) Successful in 3m0s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
Add Telegram notification for news publication to users
- 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.
2026-07-14 07:19:19 +03:00

278 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}