Implement activation checks across various commands and queries
- 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:
@@ -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<T> 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);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user