Implement activation checks across various commands and queries
CI / Backend (build + test) (push) Successful in 1m26s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s

- Introduced `IRequiresActivation` interface to enforce activation requirements for multiple commands and queries, ensuring that only activated users can create, edit, or access configurations, news, and applications.
- Updated the `RequireActivationBehavior` to handle activation checks uniformly, returning appropriate errors for unauthenticated or inactive users.
- Enhanced error handling by adding `NotActivated` error to provide clear feedback for users attempting to access restricted features.
- Updated documentation to reflect the new activation requirements and their implications on user access and functionality.
This commit is contained in:
Leonid Pershin
2026-07-13 18:51:03 +03:00
parent 7f9a441050
commit 14b64a3140
23 changed files with 183 additions and 45 deletions
+6 -2
View File
@@ -73,8 +73,12 @@
Понижение роли — грандфазеринг (лишние конфиги не отзываются, новые блокируются до входа в квоту).
- **`limitIp`** выставляется автоматически по `MaxIpLimit` роли при создании клиента (`Create`/`Rotate`);
панель не даёт настраивать его per-конфиг и не трогает уже созданных клиентов при смене роли/квоты.
- **Активация**: новый пользователь `IsActivated=false`, роль `user`; конфиги создаёт только
активированный. `ActivationRequest` одобряет админ на сайте или в Telegram — одними командами.
- **Активация**: новый пользователь `IsActivated=false`, роль `user`; неактивированному недоступны
конфиги (создание/просмотр/редактирование/ротация/отзыв/ссылка/подписка), новости и каталог
приложений — единая проверка `RequireActivationBehavior` по маркеру `IRequiresActivation` (не
разбросанные `if` в хендлерах). На фронте до активации доступны только дашборд (форма запроса
активации) и настройки аккаунта. `ActivationRequest` одобряет админ на сайте или в Telegram —
одними командами.
- **Инбаунды по ролям** (`Inbound.AllowedRoles`, M:N): создание конфига проверяет активацию + квоту роли
(в транзакции — гонки параллельных созданий) + `AllowedRoles` + включённость ноды.
- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` в 3x-ui; в `AuditLog`.
@@ -4,4 +4,4 @@ using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Apps;
public sealed record ListAppsQuery : IQuery<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>;
public sealed record ListAppsQuery : IQuery<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>, IRequiresActivation;
@@ -21,4 +21,7 @@ public static class AuthErrors
public static readonly Error UserBlocked =
Error.Forbidden("Auth.UserBlocked", "Аккаунт заблокирован администратором.");
public static readonly Error NotActivated =
Error.Forbidden("Auth.NotActivated", "Аккаунт не активирован — обратитесь к администратору.");
}
@@ -0,0 +1,32 @@
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Behaviors;
/// <summary>
/// Единая точка проверки активации для запросов, реализующих <see cref="IRequiresActivation"/> —
/// вместо разбросанных if(!profile.IsActivated) по хендлерам. Применяется только к запросам с этим
/// маркером (generic-ограничение), остальные проходят мимо.
/// </summary>
public sealed class RequireActivationBehavior<TRequest, TResponse>(ICurrentUser currentUser, IIdentityService identityService)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequiresActivation
where TResponse : Result
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return ResultFailureFactory.Create<TResponse>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return ResultFailureFactory.Create<TResponse>(AuthErrors.Unauthorized);
if (!profile.IsActivated)
return ResultFailureFactory.Create<TResponse>(AuthErrors.NotActivated);
return await next();
}
}
@@ -0,0 +1,20 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Behaviors;
/// <summary>Строит Result/Result&lt;T&gt; failure-ответ через reflection — общий хелпер для generic pipeline behaviors.</summary>
internal static class ResultFailureFactory
{
public static TResponse Create<TResponse>(Error error) where TResponse : Result
{
if (typeof(TResponse) == typeof(Result))
return (TResponse)(object)Result.Failure(error);
var valueType = typeof(TResponse).GetGenericArguments()[0];
var method = typeof(Result)
.GetMethod(nameof(Result.Failure), 1, [typeof(Error)])!
.MakeGenericMethod(valueType);
return (TResponse)method.Invoke(null, [error])!;
}
}
@@ -27,19 +27,6 @@ public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidat
"Validation.Failed",
string.Join("; ", failures.Select(f => f.ErrorMessage)));
return CreateFailure(error);
}
private static TResponse CreateFailure(Error error)
{
if (typeof(TResponse) == typeof(Result))
return (TResponse)(object)Result.Failure(error);
var valueType = typeof(TResponse).GetGenericArguments()[0];
var method = typeof(Result)
.GetMethod(nameof(Result.Failure), 1, [typeof(Error)])!
.MakeGenericMethod(valueType);
return (TResponse)method.Invoke(null, [error])!;
return ResultFailureFactory.Create<TResponse>(error);
}
}
@@ -0,0 +1,4 @@
namespace PnvPanel.Application.Common.Messaging;
/// <summary>Маркер: запрос доступен только активированным пользователям — проверяется RequireActivationBehavior.</summary>
public interface IRequiresActivation;
@@ -4,9 +4,6 @@ namespace PnvPanel.Application.Configs;
public static class ConfigErrors
{
public static readonly Error NotActivated =
Error.Forbidden("Configs.NotActivated", "Аккаунт не активирован — обратитесь к администратору.");
public static readonly Error InboundNotAvailable =
Error.NotFound("Configs.InboundNotAvailable", "Инбаунд недоступен.");
@@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Create;
public sealed record CreateVpnConfigCommand(Guid InboundId, string? Label) : ICommand<Result<VpnConfigDto>>;
public sealed record CreateVpnConfigCommand(Guid InboundId, string? Label) : ICommand<Result<VpnConfigDto>>, IRequiresActivation;
@@ -20,9 +20,6 @@ public sealed class CreateVpnConfigCommandHandler(
if (profile is null)
return Result.Failure<VpnConfigDto>(AuthErrors.Unauthorized);
if (!profile.IsActivated)
return Result.Failure<VpnConfigDto>(ConfigErrors.NotActivated);
var inbound = await dbContext.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken);
@@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Edit;
public sealed record EditVpnConfigCommand(Guid ConfigId, string? Label) : ICommand<Result<VpnConfigDto>>;
public sealed record EditVpnConfigCommand(Guid ConfigId, string? Label) : ICommand<Result<VpnConfigDto>>, IRequiresActivation;
@@ -3,7 +3,7 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.GetConfigLink;
public sealed record GetConfigLinkQuery(Guid ConfigId) : IQuery<Result<ConfigLinkDto>>;
public sealed record GetConfigLinkQuery(Guid ConfigId) : IQuery<Result<ConfigLinkDto>>, IRequiresActivation;
/// <summary>SubscriptionToken — Api-слой строит из него абсолютный URL (знает scheme/host запроса).</summary>
public sealed record ConfigLinkDto(string ConnectionString, string SubscriptionToken);
@@ -3,6 +3,6 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.GetMyConfigs;
public sealed record GetMyConfigsQuery : IQuery<Result<GetMyConfigsResult>>;
public sealed record GetMyConfigsQuery : IQuery<Result<GetMyConfigsResult>>, IRequiresActivation;
public sealed record GetMyConfigsResult(IReadOnlyList<VpnConfigDto> Configs, int MaxConfigs);
@@ -3,7 +3,7 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.GetMySubscription;
public sealed record GetMySubscriptionQuery : IQuery<Result<MySubscriptionDto>>;
public sealed record GetMySubscriptionQuery : IQuery<Result<MySubscriptionDto>>, IRequiresActivation;
/// <summary>SubscriptionToken — Api-слой строит из него абсолютный URL (знает scheme/host запроса), см. GetConfigLinkQuery.</summary>
public sealed record MySubscriptionDto(string SubscriptionToken);
@@ -4,7 +4,7 @@ using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Application.Configs.ListAvailableInbounds;
public sealed record ListAvailableInboundsQuery : IQuery<Result<IReadOnlyList<AvailableInboundDto>>>;
public sealed record ListAvailableInboundsQuery : IQuery<Result<IReadOnlyList<AvailableInboundDto>>>, IRequiresActivation;
/// <summary>Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui.</summary>
public sealed record AvailableInboundDto(Guid InboundId, string DisplayName, VpnProtocol Protocol);
@@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Revoke;
public sealed record RevokeVpnConfigCommand(Guid ConfigId) : ICommand<Result>;
public sealed record RevokeVpnConfigCommand(Guid ConfigId) : ICommand<Result>, IRequiresActivation;
@@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Rotate;
public sealed record RotateVpnConfigCommand(Guid ConfigId) : ICommand<Result<VpnConfigDto>>;
public sealed record RotateVpnConfigCommand(Guid ConfigId) : ICommand<Result<VpnConfigDto>>, IRequiresActivation;
@@ -22,9 +22,10 @@ public static class DependencyInjection
RegisterClosedGeneric(services, assembly, typeof(IQueryHandler<,>));
RegisterClosedGeneric(services, assembly, typeof(IValidator<>));
// Порядок важен: Logging (снаружи) -> Validation -> UnitOfWork (ближе к хендлеру).
// Порядок важен: Logging (снаружи) -> Validation -> RequireActivation -> UnitOfWork (ближе к хендлеру).
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequireActivationBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(UnitOfWorkBehavior<,>));
return services;
@@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.News;
public sealed record ListNewsQuery(int Page, int PageSize) : IQuery<Result<PagedList<NewsPostDto>>>;
public sealed record ListNewsQuery(int Page, int PageSize) : IQuery<Result<PagedList<NewsPostDto>>>, IRequiresActivation;
@@ -0,0 +1,85 @@
using NSubstitute;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Behaviors;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using Xunit;
namespace PnvPanel.Application.Tests.Common.Behaviors;
public class RequireActivationBehaviorTests
{
private sealed record DummyRequest : IRequiresActivation;
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private RequireActivationBehavior<DummyRequest, Result<string>> CreateBehavior() => new(_currentUser, _identityService);
private static CurrentUserProfile CreateProfile(Guid userId, bool isActivated) =>
new(userId, "alice", Guid.NewGuid(), "user", IsActivated: isActivated, IsBlocked: false, MaxConfigs: 3,
MaxIpLimit: RoleQuota.Unlimited, SubscriptionToken: "sub-token");
[Fact]
public async Task Handle_WhenActivated_CallsNext()
{
var userId = Guid.NewGuid();
_currentUser.UserId.Returns(userId);
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(CreateProfile(userId, isActivated: true));
var result = await CreateBehavior().Handle(
new DummyRequest(), () => Task.FromResult(Result.Success("ok")), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal("ok", result.Value);
}
[Fact]
public async Task Handle_WhenNotActivated_ReturnsNotActivatedWithoutCallingNext()
{
var userId = Guid.NewGuid();
_currentUser.UserId.Returns(userId);
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(CreateProfile(userId, isActivated: false));
var nextCalled = false;
var result = await CreateBehavior().Handle(
new DummyRequest(),
() =>
{
nextCalled = true;
return Task.FromResult(Result.Success("ok"));
},
CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(AuthErrors.NotActivated, result.Error);
Assert.False(nextCalled);
}
[Fact]
public async Task Handle_WhenNoCurrentUser_ReturnsUnauthorized()
{
_currentUser.UserId.Returns((Guid?)null);
var result = await CreateBehavior().Handle(
new DummyRequest(), () => Task.FromResult(Result.Success("ok")), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(AuthErrors.Unauthorized, result.Error);
}
[Fact]
public async Task Handle_WhenProfileMissing_ReturnsUnauthorized()
{
var userId = Guid.NewGuid();
_currentUser.UserId.Returns(userId);
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns((CurrentUserProfile?)null);
var result = await CreateBehavior().Handle(
new DummyRequest(), () => Task.FromResult(Result.Success("ok")), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(AuthErrors.Unauthorized, result.Error);
}
}
+3 -3
View File
@@ -79,8 +79,8 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
запрашивает `GET .../link` отдельно, по кнопке на карточке конфига; QR строится на фронте из
`connectionString`.
`POST /api/configs` без активации → `403` (`Configs.NotActivated`); сверх квоты роли → `409`
(`Configs.QuotaExceeded`).
Все `/api/configs/*`, `/api/news`, `/api/apps` без активации → `403` (`Auth.NotActivated`, единая
проверка `RequireActivationBehavior`); создание сверх квоты роли → `409` (`Configs.QuotaExceeded`).
## Apps — каталог приложений
@@ -222,7 +222,7 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи
| --- | -------------------------------------------------------------------- |
| 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) |
| 401 | Нет/просрочен/невалиден access-токен |
| 403 | Нет прав по роли, либо `Configs.NotActivated` |
| 403 | Нет прав по роли, либо `Auth.NotActivated` |
| 404 | Ресурс не найден |
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации |
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
+11 -7
View File
@@ -73,9 +73,11 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
[backend-conventions.md](backend-conventions.md)).
- **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом
DTO, без маппера (Mapster/AutoMapper).
- **Pipeline behaviors**: `ValidationBehavior`, `LoggingBehavior`, `UnitOfWorkBehavior` (транзакция +
`SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` нет — авторизация (роль,
активация) — это либо `RequireAuthorization()`/`RequireRole(...)` на эндпоинте, либо явная проверка
- **Pipeline behaviors** (порядок: Logging → Validation → RequireActivation → UnitOfWork):
`LoggingBehavior`, `ValidationBehavior`, `RequireActivationBehavior` (403 `Auth.NotActivated` для
запросов с маркером `IRequiresActivation` — конфиги, новости, каталог приложений), `UnitOfWorkBehavior`
(транзакция + `SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` для ролей нет —
роль проверяется через `RequireAuthorization()`/`RequireRole(...)` на эндпоинте либо явной проверкой
в начале хендлера (например, «инбаунд доступен роли пользователя»).
- **Result<T>**: явная модель успеха/ошибки (`Result`/`Result<T>`, `Error` с `ErrorType`) вместо
исключений для управляемых сценариев.
@@ -131,10 +133,11 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
Пример потока «создать конфиг» (`backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs`):
```
POST /api/configs
→ CreateVpnConfigCommand
→ CreateVpnConfigCommand (IRequiresActivation)
→ ValidationBehavior (FluentValidation — формат inboundId/label)
→ RequireActivationBehavior (403 Auth.NotActivated, если аккаунт не активирован)
→ CreateVpnConfigCommandHandler
· проверяет активацию + роль инбаунда (доменные проверки)
· проверяет роль инбаунда (доменная проверка)
· SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания
· пересчитывает текущее число активных конфигов и сверяет с AppRole.MaxConfigs
· IXuiPanelGateway.AddClientAsync(node, inbound, ...) // 3x-ui, получает ClientExternalId
@@ -253,8 +256,9 @@ POST /api/configs
вошедшим — `POST /api/auth/change-password`.
- **AuthZ**: именованных policy нет — либо `.RequireAuthorization()` (любой вошедший) или
`.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))` на группе эндпоинтов, либо
явная проверка внутри хендлера (владение конфигом — сравнение `VpnConfig.UserId` с `ICurrentUser`;
активация — `ConfigErrors.NotActivated`).
явная проверка внутри хендлера (владение конфигом — сравнение `VpnConfig.UserId` с `ICurrentUser`).
Активация — не в хендлере, а в pipeline behavior (`RequireActivationBehavior`, срабатывает на
запросах с маркером `IRequiresActivation`: конфиги, новости, каталог приложений) — `AuthErrors.NotActivated`.
- **Секреты нод**: шифруются `ISecretProtector` (Data Protection) перед сохранением; в API/логи не попадают.
- **CSRF**: явного анти-CSRF токена нет — все мутации API читают авторизацию только из
`Authorization: Bearer` (JS должен явно прочитать access-token из памяти и подставить заголовок,
+6 -2
View File
@@ -107,7 +107,10 @@ NewsPost (лента новостей; публикует
трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp`
всегда `null` — «не менять»).
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
- **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`).
- **Доступ разрешён только активированному пользователю** (`AppUser.IsActivated == true`): создание,
просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также
чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline
behavior, маркер `IRequiresActivation` на команде/запросе), а не разбросанные проверки в хендлерах.
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже.
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
@@ -196,7 +199,8 @@ NewsPost (лента новостей; публикует
| `TelegramLinkedAt` | `DateTimeOffset?` | Когда привязан |
Инварианты: один `TelegramUserId` ↔ один аккаунт (повторная привязка требует `/unlink`);
неактивированный пользователь не может создавать конфиги; при регистрации выдаётся роль `user`.
неактивированный пользователь не имеет доступа к конфигам, новостям и каталогу приложений (см. выше);
при регистрации выдаётся роль `user`.
**Блокировка** (`IsBlocked = true`) переводит все конфиги в `Disabled` (отключение клиентов в 3x-ui);
разблокировка включает их обратно. У пользователя ровно одна роль.