feat(security): emit authentication failures for firewall handling
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the HTTP request for the duration of the current runtime execution.
|
||||||
|
*/
|
||||||
|
final class RequestContext
|
||||||
|
{
|
||||||
|
private ?Request $request = null;
|
||||||
|
|
||||||
|
public function initialize(Request $request): void
|
||||||
|
{
|
||||||
|
if ($this->request !== null) {
|
||||||
|
throw new \LogicException('The request context has already been initialized.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->request = $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function current(): ?Request
|
||||||
|
{
|
||||||
|
return $this->request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->request = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
AuthenticationFailedEvent::class,
|
AuthenticationFailedEvent::class,
|
||||||
FirewallService::class,
|
FirewallService::class,
|
||||||
'handleAuthFailure',
|
'handleAuthFailure',
|
||||||
|
DeliveryMode::Immediate,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use KTXC\Http\Middleware\MiddlewarePipeline;
|
|||||||
use KTXC\Http\Middleware\RouterMiddleware;
|
use KTXC\Http\Middleware\RouterMiddleware;
|
||||||
use KTXC\Http\Middleware\TenantMiddleware;
|
use KTXC\Http\Middleware\TenantMiddleware;
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
|
use KTXC\Http\Request\RequestContext;
|
||||||
use KTXC\Http\Response\Response;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\KernelInterface;
|
use KTXC\KernelInterface;
|
||||||
|
|
||||||
@@ -25,26 +26,34 @@ final class HttpRuntime
|
|||||||
public function run(?Request $request = null, bool $send = true): Response
|
public function run(?Request $request = null, bool $send = true): Response
|
||||||
{
|
{
|
||||||
$request ??= Request::createFromGlobals();
|
$request ??= Request::createFromGlobals();
|
||||||
|
$requestContext = null;
|
||||||
|
|
||||||
return $this->kernel->executionRunner()->execute(
|
try {
|
||||||
ExecutionDescriptor::http(),
|
return $this->kernel->executionRunner()->execute(
|
||||||
function () use ($request, $send): Response {
|
ExecutionDescriptor::http(),
|
||||||
$response = $this->pipeline()->handle($request);
|
function () use ($request, $send, &$requestContext): Response {
|
||||||
if ($send) {
|
$requestContext = $this->kernel->container()->get(RequestContext::class);
|
||||||
$response->send();
|
$requestContext->initialize($request);
|
||||||
}
|
|
||||||
|
|
||||||
return $response;
|
$response = $this->pipeline()->handle($request);
|
||||||
},
|
if ($send) {
|
||||||
function (\Throwable $error) use ($send): Response {
|
$response->send();
|
||||||
$response = $this->errorResponse($error);
|
}
|
||||||
if ($send) {
|
|
||||||
$response->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
},
|
},
|
||||||
);
|
function (\Throwable $error) use ($send): Response {
|
||||||
|
$response = $this->errorResponse($error);
|
||||||
|
if ($send) {
|
||||||
|
$response->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
$requestContext?->clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pipeline(): MiddlewarePipeline
|
private function pipeline(): MiddlewarePipeline
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ use KTXC\Models\Identity\User;
|
|||||||
use KTXC\Resource\ProviderManager;
|
use KTXC\Resource\ProviderManager;
|
||||||
use KTXC\Security\Authentication\AuthenticationRequest;
|
use KTXC\Security\Authentication\AuthenticationRequest;
|
||||||
use KTXC\Security\Authentication\AuthenticationResponse;
|
use KTXC\Security\Authentication\AuthenticationResponse;
|
||||||
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
use KTXC\Service\TokenService;
|
use KTXC\Service\TokenService;
|
||||||
use KTXC\Service\UserAccountsService;
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\Context\TenantContextInterface;
|
||||||
use KTXF\Cache\CacheScope;
|
use KTXF\Cache\CacheScope;
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||||
use KTXF\Security\Authentication\AuthenticationSession;
|
use KTXF\Security\Authentication\AuthenticationSession;
|
||||||
use KTXF\Security\Authentication\ProviderContext;
|
use KTXF\Security\Authentication\ProviderContext;
|
||||||
@@ -31,6 +33,7 @@ class AuthenticationManager
|
|||||||
private readonly ProviderManager $providerManager,
|
private readonly ProviderManager $providerManager,
|
||||||
private readonly TokenService $tokenService,
|
private readonly TokenService $tokenService,
|
||||||
private readonly UserAccountsService $userService,
|
private readonly UserAccountsService $userService,
|
||||||
|
private readonly EventDispatcherInterface $events,
|
||||||
) {
|
) {
|
||||||
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
||||||
}
|
}
|
||||||
@@ -187,6 +190,10 @@ class AuthenticationManager
|
|||||||
|
|
||||||
if (!$result->isSuccess()) {
|
if (!$result->isSuccess()) {
|
||||||
$this->saveSession($session);
|
$this->saveSession($session);
|
||||||
|
$this->publishAuthenticationFailure(
|
||||||
|
$session,
|
||||||
|
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||||
|
);
|
||||||
return AuthenticationResponse::failed(
|
return AuthenticationResponse::failed(
|
||||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||||
'Authentication failed. If you haven\'t set up this method, try another option.',
|
'Authentication failed. If you haven\'t set up this method, try another option.',
|
||||||
@@ -389,6 +396,10 @@ class AuthenticationManager
|
|||||||
$result = $provider->completeRedirect($context, $request->params);
|
$result = $provider->completeRedirect($context, $request->params);
|
||||||
|
|
||||||
if ($result->isFailed()) {
|
if ($result->isFailed()) {
|
||||||
|
$this->publishAuthenticationFailure(
|
||||||
|
$session,
|
||||||
|
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||||
|
);
|
||||||
$this->deleteSession($session->id);
|
$this->deleteSession($session->id);
|
||||||
return AuthenticationResponse::failed(
|
return AuthenticationResponse::failed(
|
||||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||||
@@ -566,6 +577,17 @@ class AuthenticationManager
|
|||||||
// Helper Methods
|
// Helper Methods
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
|
private function publishAuthenticationFailure(
|
||||||
|
AuthenticationSession $session,
|
||||||
|
string $reason,
|
||||||
|
): void {
|
||||||
|
$this->events->dispatch(new AuthenticationFailedEvent(
|
||||||
|
userId: $session->userIdentifier,
|
||||||
|
reason: $reason,
|
||||||
|
tenantId: $session->tenantIdentifier,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build provider context from session
|
* Build provider context from session
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,15 +9,10 @@ use KTXF\Event\Event;
|
|||||||
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
|
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $ipAddress,
|
|
||||||
private readonly ?string $deviceFingerprint = null,
|
|
||||||
private readonly ?string $userId = null,
|
private readonly ?string $userId = null,
|
||||||
private readonly ?string $reason = null,
|
private readonly ?string $reason = null,
|
||||||
?string $tenantId = null,
|
?string $tenantId = null,
|
||||||
?string $identityId = null,
|
?string $identityId = null,
|
||||||
private readonly ?string $userAgent = null,
|
|
||||||
private readonly ?string $requestPath = null,
|
|
||||||
private readonly ?string $requestMethod = null,
|
|
||||||
) {
|
) {
|
||||||
parent::__construct(
|
parent::__construct(
|
||||||
self::class,
|
self::class,
|
||||||
@@ -27,31 +22,6 @@ final class AuthenticationFailedEvent extends Event implements SecurityEventInte
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getIpAddress(): string
|
|
||||||
{
|
|
||||||
return $this->ipAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string
|
|
||||||
{
|
|
||||||
return $this->deviceFingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string
|
|
||||||
{
|
|
||||||
return $this->userAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string
|
|
||||||
{
|
|
||||||
return $this->requestPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string
|
|
||||||
{
|
|
||||||
return $this->requestMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
public function getUserId(): ?string
|
||||||
{
|
{
|
||||||
return $this->userId;
|
return $this->userId;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use KTXF\Event\Event;
|
|||||||
/**
|
/**
|
||||||
* Security-specific event for authentication and access control events
|
* Security-specific event for authentication and access control events
|
||||||
*/
|
*/
|
||||||
final class SecurityEvent extends Event implements SecurityEventInterface
|
final class SecurityEvent extends Event implements SecurityRequestEventInterface
|
||||||
{
|
{
|
||||||
// Event names
|
// Event names
|
||||||
public const AUTH_SUCCESS = 'security.auth.success';
|
public const AUTH_SUCCESS = 'security.auth.success';
|
||||||
|
|||||||
@@ -18,16 +18,6 @@ interface SecurityEventInterface
|
|||||||
|
|
||||||
public function getIdentityId(): ?string;
|
public function getIdentityId(): ?string;
|
||||||
|
|
||||||
public function getIpAddress(): ?string;
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string;
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string;
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string;
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string;
|
|
||||||
|
|
||||||
public function getUserId(): ?string;
|
public function getUserId(): ?string;
|
||||||
|
|
||||||
public function getReason(): ?string;
|
public function getReason(): ?string;
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
interface SecurityRequestEventInterface extends SecurityEventInterface
|
||||||
|
{
|
||||||
|
public function getIpAddress(): ?string;
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string;
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string;
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string;
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace KTXC\Service;
|
namespace KTXC\Service;
|
||||||
|
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
|
use KTXC\Http\Request\RequestContext;
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
use KTXC\Models\Firewall\FirewallLogObject;
|
||||||
use KTXC\Stores\FirewallStore;
|
use KTXC\Stores\FirewallStore;
|
||||||
@@ -12,6 +13,7 @@ use KTXC\Context\TenantContextInterface;
|
|||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
use KTXC\Security\Event\SecurityEvent;
|
||||||
use KTXC\Security\Event\SecurityEventInterface;
|
use KTXC\Security\Event\SecurityEventInterface;
|
||||||
|
use KTXC\Security\Event\SecurityRequestEventInterface;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXF\IpUtils;
|
use KTXF\IpUtils;
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ class FirewallService
|
|||||||
private readonly EventDispatcherInterface $events,
|
private readonly EventDispatcherInterface $events,
|
||||||
private readonly FirewallRuleManager $rules,
|
private readonly FirewallRuleManager $rules,
|
||||||
private readonly FirewallRuleCache $ruleCache,
|
private readonly FirewallRuleCache $ruleCache,
|
||||||
|
private readonly RequestContext $requestContext,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,14 +137,15 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
public function handleAuthFailure(AuthenticationFailedEvent $event): void
|
public function handleAuthFailure(AuthenticationFailedEvent $event): void
|
||||||
{
|
{
|
||||||
$ipAddress = $event->getIpAddress();
|
$request = $this->requestContext->current();
|
||||||
|
$ipAddress = $request?->getClientIp();
|
||||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
||||||
|
|
||||||
if (!$ipAddress || !$tenantId) {
|
if (!$ipAddress || !$tenantId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$log = $this->securityLog($event);
|
$log = $this->securityLog($event, $request);
|
||||||
if ($log === null || !$this->store->createLogOnce($log)) {
|
if ($log === null || !$this->store->createLogOnce($log)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -235,7 +239,10 @@ class FirewallService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function securityLog(SecurityEventInterface $event): ?FirewallLogObject
|
private function securityLog(
|
||||||
|
SecurityEventInterface $event,
|
||||||
|
?Request $request = null,
|
||||||
|
): ?FirewallLogObject
|
||||||
{
|
{
|
||||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
||||||
$ruleScope = $event->get('ruleScope');
|
$ruleScope = $event->get('ruleScope');
|
||||||
@@ -243,14 +250,19 @@ class FirewallService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$requestEvent = $event instanceof SecurityRequestEventInterface ? $event : null;
|
||||||
|
|
||||||
$log = new FirewallLogObject();
|
$log = new FirewallLogObject();
|
||||||
return $log->setEventId($event->getEventId())
|
return $log->setEventId($event->getEventId())
|
||||||
->setTenantId($tenantId)
|
->setTenantId($tenantId)
|
||||||
->setIpAddress($event->getIpAddress())
|
->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress())
|
||||||
->setDeviceFingerprint($event->getDeviceFingerprint())
|
->setDeviceFingerprint(
|
||||||
->setUserAgent($event->getUserAgent())
|
$request?->headers->get('X-Device-Fingerprint')
|
||||||
->setRequestPath($event->getRequestPath())
|
?? $requestEvent?->getDeviceFingerprint()
|
||||||
->setRequestMethod($event->getRequestMethod())
|
)
|
||||||
|
->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent())
|
||||||
|
->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath())
|
||||||
|
->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod())
|
||||||
->setEventType($this->mapEventToLogType($event->getName()))
|
->setEventType($this->mapEventToLogType($event->getName()))
|
||||||
->setResult($this->mapEventToResult($event))
|
->setResult($this->mapEventToResult($event))
|
||||||
->setRuleId($event->get('ruleId'))
|
->setRuleId($event->get('ruleId'))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace KTXT\Unit\Event;
|
|||||||
|
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
use KTXC\Security\Event\SecurityEvent;
|
||||||
|
use KTXC\Security\Event\SecurityRequestEventInterface;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -17,27 +18,18 @@ final class AuthenticationFailedEventTest extends TestCase
|
|||||||
public function constructsTypedState(): void
|
public function constructsTypedState(): void
|
||||||
{
|
{
|
||||||
$event = new AuthenticationFailedEvent(
|
$event = new AuthenticationFailedEvent(
|
||||||
'203.0.113.10',
|
|
||||||
'device-a',
|
|
||||||
'user-a',
|
'user-a',
|
||||||
'Invalid credentials',
|
'Invalid credentials',
|
||||||
'tenant-a',
|
'tenant-a',
|
||||||
'identity-a',
|
'identity-a',
|
||||||
'Test Agent',
|
|
||||||
'/login',
|
|
||||||
'POST',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(AuthenticationFailedEvent::class, $event->getName());
|
self::assertSame(AuthenticationFailedEvent::class, $event->getName());
|
||||||
self::assertSame('203.0.113.10', $event->getIpAddress());
|
|
||||||
self::assertSame('device-a', $event->getDeviceFingerprint());
|
|
||||||
self::assertSame('user-a', $event->getUserId());
|
self::assertSame('user-a', $event->getUserId());
|
||||||
self::assertSame('Invalid credentials', $event->getReason());
|
self::assertSame('Invalid credentials', $event->getReason());
|
||||||
self::assertSame('tenant-a', $event->getTenantId());
|
self::assertSame('tenant-a', $event->getTenantId());
|
||||||
self::assertSame('identity-a', $event->getIdentityId());
|
self::assertSame('identity-a', $event->getIdentityId());
|
||||||
self::assertSame('Test Agent', $event->getUserAgent());
|
|
||||||
self::assertSame('/login', $event->getRequestPath());
|
|
||||||
self::assertSame('POST', $event->getRequestMethod());
|
|
||||||
self::assertSame(SecurityEvent::SEVERITY_WARNING, $event->getSeverity());
|
self::assertSame(SecurityEvent::SEVERITY_WARNING, $event->getSeverity());
|
||||||
|
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use KTXC\Http\Middleware\RequestHandlerInterface;
|
|||||||
use KTXC\Http\Middleware\RouterMiddleware;
|
use KTXC\Http\Middleware\RouterMiddleware;
|
||||||
use KTXC\Http\Middleware\TenantMiddleware;
|
use KTXC\Http\Middleware\TenantMiddleware;
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
|
use KTXC\Http\Request\RequestContext;
|
||||||
use KTXC\Http\Response\Response;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\Http\Response\StreamedResponse;
|
use KTXC\Http\Response\StreamedResponse;
|
||||||
use KTXC\KernelInterface;
|
use KTXC\KernelInterface;
|
||||||
@@ -78,6 +79,31 @@ final class HttpRuntimeTest extends TestCase
|
|||||||
self::assertTrue($kernel->outcome?->successful);
|
self::assertTrue($kernel->outcome?->successful);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('HTTP requests are available only for the active runtime execution')]
|
||||||
|
public function scopesRequestContext(): void
|
||||||
|
{
|
||||||
|
$requestContext = new RequestContext();
|
||||||
|
$observer = new RequestContextMiddleware($requestContext);
|
||||||
|
$kernel = new RuntimeKernel(
|
||||||
|
new RuntimeContainer([
|
||||||
|
RequestContext::class => $requestContext,
|
||||||
|
TenantMiddleware::class => new PassMiddleware(),
|
||||||
|
FirewallMiddleware::class => new PassMiddleware(),
|
||||||
|
AuthenticationMiddleware::class => new PassMiddleware(),
|
||||||
|
RouterMiddleware::class => $observer,
|
||||||
|
]),
|
||||||
|
new TenantContext($this->createStub(TenantService::class)),
|
||||||
|
new IdentityContext(),
|
||||||
|
);
|
||||||
|
$request = Request::create('/auth/verify', 'POST');
|
||||||
|
|
||||||
|
(new HttpRuntime($kernel, false))->run($request, send: false);
|
||||||
|
|
||||||
|
self::assertSame($request, $observer->observed);
|
||||||
|
self::assertNull($requestContext->current());
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestDox('HTTP exceptions render a response and still terminate')]
|
#[TestDox('HTTP exceptions render a response and still terminate')]
|
||||||
public function fails(): void
|
public function fails(): void
|
||||||
@@ -198,9 +224,12 @@ final class RuntimeKernel implements KernelInterface
|
|||||||
|
|
||||||
final class RuntimeContainer implements ContainerInterface
|
final class RuntimeContainer implements ContainerInterface
|
||||||
{
|
{
|
||||||
|
private readonly array $services;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly array $services,
|
array $services,
|
||||||
) {
|
) {
|
||||||
|
$this->services = [RequestContext::class => new RequestContext(), ...$services];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get(string $id): mixed
|
public function get(string $id): mixed
|
||||||
@@ -250,6 +279,21 @@ final class ResponseMiddleware extends PassMiddleware
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class RequestContextMiddleware extends PassMiddleware
|
||||||
|
{
|
||||||
|
public ?Request $observed = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RequestContext $requestContext,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||||
|
{
|
||||||
|
$this->observed = $this->requestContext->current();
|
||||||
|
return new Response('ok');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class ThrowingMiddleware extends PassMiddleware
|
final class ThrowingMiddleware extends PassMiddleware
|
||||||
{
|
{
|
||||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Security;
|
||||||
|
|
||||||
|
use KTXC\Context\TenantContextInterface;
|
||||||
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXC\Security\Authentication\AuthenticationRequest;
|
||||||
|
use KTXC\Security\AuthenticationManager;
|
||||||
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
|
use KTXC\Service\TokenService;
|
||||||
|
use KTXC\Service\UserAccountsService;
|
||||||
|
use KTXF\Cache\CacheScope;
|
||||||
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
|
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||||
|
use KTXF\Security\Authentication\AuthenticationSession;
|
||||||
|
use KTXF\Security\Authentication\ProviderContext;
|
||||||
|
use KTXF\Security\Authentication\ProviderResult;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class AuthenticationManagerTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function rejectedCredentialPublishesAuthenticationFailedEvent(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createStub(TenantContextInterface::class);
|
||||||
|
$tenant->method('configuration')->willReturn(new TenantConfiguration());
|
||||||
|
|
||||||
|
$session = new AuthenticationSession(
|
||||||
|
id: 'auth-session',
|
||||||
|
state: AuthenticationSession::STATE_IDENTIFIED,
|
||||||
|
tenantIdentifier: 'tenant-a',
|
||||||
|
userIdentifier: 'user-a',
|
||||||
|
userIdentity: 'person@example.com',
|
||||||
|
methodsAvailable: ['password'],
|
||||||
|
createdAt: time(),
|
||||||
|
expiresAt: time() + 300,
|
||||||
|
);
|
||||||
|
|
||||||
|
$cache = $this->createMock(EphemeralCacheInterface::class);
|
||||||
|
$cache->expects($this->once())
|
||||||
|
->method('get')
|
||||||
|
->with('auth-session', CacheScope::Tenant, 'auth')
|
||||||
|
->willReturn($session);
|
||||||
|
$cache->expects($this->once())
|
||||||
|
->method('set')
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$provider = new class implements AuthenticationProviderInterface {
|
||||||
|
public int $verificationCount = 0;
|
||||||
|
|
||||||
|
public function type(): string
|
||||||
|
{
|
||||||
|
return 'authentication';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function identifier(): string
|
||||||
|
{
|
||||||
|
return 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Password';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Test provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function method(): string
|
||||||
|
{
|
||||||
|
return self::METHOD_CREDENTIAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function icon(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(ProviderContext $context, string $secret): ProviderResult
|
||||||
|
{
|
||||||
|
$this->verificationCount++;
|
||||||
|
return ProviderResult::failed(ProviderResult::ERROR_INVALID_FACTOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginChallenge(ProviderContext $context): ProviderResult
|
||||||
|
{
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
|
||||||
|
{
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginRedirect(
|
||||||
|
ProviderContext $context,
|
||||||
|
string $callbackUrl,
|
||||||
|
?string $returnUrl = null,
|
||||||
|
): ProviderResult {
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
|
||||||
|
{
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$providers = $this->createMock(ProviderManager::class);
|
||||||
|
$providers->method('resolve')->with('authentication', 'password')->willReturn($provider);
|
||||||
|
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects($this->once())
|
||||||
|
->method('dispatch')
|
||||||
|
->with(self::callback(static function ($event): bool {
|
||||||
|
return $event instanceof AuthenticationFailedEvent
|
||||||
|
&& $event->getUserId() === 'user-a'
|
||||||
|
&& $event->getReason() === ProviderResult::ERROR_INVALID_FACTOR
|
||||||
|
&& $event->getTenantId() === 'tenant-a';
|
||||||
|
}));
|
||||||
|
|
||||||
|
$manager = new AuthenticationManager(
|
||||||
|
$tenant,
|
||||||
|
$cache,
|
||||||
|
$providers,
|
||||||
|
$this->createStub(TokenService::class),
|
||||||
|
$this->createStub(UserAccountsService::class),
|
||||||
|
$events,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $manager->handle(AuthenticationRequest::verify(
|
||||||
|
'auth-session',
|
||||||
|
'password',
|
||||||
|
'incorrect',
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertTrue($response->isFailed());
|
||||||
|
self::assertSame(1, $provider->verificationCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace KTXT\Unit\Service;
|
namespace KTXT\Unit\Service;
|
||||||
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\Context\TenantContextInterface;
|
||||||
|
use KTXC\Http\Request\Request;
|
||||||
|
use KTXC\Http\Request\RequestContext;
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
use KTXC\Models\Firewall\FirewallLogObject;
|
||||||
use KTXC\Models\Tenant\TenantConfiguration;
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
@@ -25,6 +27,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
private FirewallStore&MockObject $store;
|
private FirewallStore&MockObject $store;
|
||||||
private TenantContextInterface&MockObject $tenantContext;
|
private TenantContextInterface&MockObject $tenantContext;
|
||||||
private EventDispatcherInterface&MockObject $events;
|
private EventDispatcherInterface&MockObject $events;
|
||||||
|
private RequestContext $requestContext;
|
||||||
private FirewallService $service;
|
private FirewallService $service;
|
||||||
private ?string $currentTenant;
|
private ?string $currentTenant;
|
||||||
private ?TenantConfiguration $currentConfiguration;
|
private ?TenantConfiguration $currentConfiguration;
|
||||||
@@ -34,6 +37,16 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->store = $this->createMock(FirewallStore::class);
|
$this->store = $this->createMock(FirewallStore::class);
|
||||||
$this->tenantContext = $this->createMock(TenantContextInterface::class);
|
$this->tenantContext = $this->createMock(TenantContextInterface::class);
|
||||||
$this->events = $this->createMock(EventDispatcherInterface::class);
|
$this->events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$this->requestContext = new RequestContext();
|
||||||
|
$this->requestContext->initialize(Request::create(
|
||||||
|
'/login',
|
||||||
|
'POST',
|
||||||
|
server: [
|
||||||
|
'REMOTE_ADDR' => '203.0.113.10',
|
||||||
|
'HTTP_USER_AGENT' => 'Test Agent',
|
||||||
|
'HTTP_X_DEVICE_FINGERPRINT' => 'device-a',
|
||||||
|
],
|
||||||
|
));
|
||||||
$this->currentTenant = 'tenant-a';
|
$this->currentTenant = 'tenant-a';
|
||||||
$this->currentConfiguration = null;
|
$this->currentConfiguration = null;
|
||||||
$this->tenantContext->method('identifier')->willReturnCallback(
|
$this->tenantContext->method('identifier')->willReturnCallback(
|
||||||
@@ -49,7 +62,8 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->tenantContext,
|
$this->tenantContext,
|
||||||
$this->events,
|
$this->events,
|
||||||
$manager,
|
$manager,
|
||||||
$cache
|
$cache,
|
||||||
|
$this->requestContext,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +267,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->store->expects($this->never())->method('createLog');
|
$this->store->expects($this->never())->method('createLog');
|
||||||
|
|
||||||
$this->service->logSecurityEvent(
|
$this->service->logSecurityEvent(
|
||||||
new AuthenticationFailedEvent('203.0.113.10')
|
new AuthenticationFailedEvent()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,16 +392,53 @@ class FirewallServiceTest extends TestCase
|
|||||||
->willReturn(4);
|
->willReturn(4);
|
||||||
$this->events->expects($this->never())->method('dispatch');
|
$this->events->expects($this->never())->method('dispatch');
|
||||||
|
|
||||||
$event = new AuthenticationFailedEvent(
|
$event = new AuthenticationFailedEvent(tenantId: 'tenant-a');
|
||||||
'203.0.113.10',
|
|
||||||
tenantId: 'tenant-a',
|
|
||||||
);
|
|
||||||
$this->service->handleAuthFailure($event);
|
$this->service->handleAuthFailure($event);
|
||||||
|
|
||||||
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
|
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
|
||||||
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
|
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[TestDox('Authentication failures combine event facts with the current request')]
|
||||||
|
public function testAuthenticationFailureRequestContext(): void
|
||||||
|
{
|
||||||
|
$this->store->expects($this->once())
|
||||||
|
->method('createLogOnce')
|
||||||
|
->with(self::callback(static function (FirewallLogObject $log): bool {
|
||||||
|
return $log->getIpAddress() === '203.0.113.10'
|
||||||
|
&& $log->getDeviceFingerprint() === 'device-a'
|
||||||
|
&& $log->getUserAgent() === 'Test Agent'
|
||||||
|
&& $log->getRequestPath() === '/login'
|
||||||
|
&& $log->getRequestMethod() === 'POST'
|
||||||
|
&& $log->getIdentityId() === 'user-a'
|
||||||
|
&& $log->getMetadata()['reason'] === 'invalid_credentials';
|
||||||
|
}))
|
||||||
|
->willReturn(true);
|
||||||
|
$this->store->expects($this->once())
|
||||||
|
->method('countRecentFailures')
|
||||||
|
->with('tenant-a', '203.0.113.10', 300)
|
||||||
|
->willReturn(0);
|
||||||
|
|
||||||
|
$this->service->handleAuthFailure(new AuthenticationFailedEvent(
|
||||||
|
userId: 'user-a',
|
||||||
|
reason: 'invalid_credentials',
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('Authentication failures outside HTTP request context are ignored')]
|
||||||
|
public function testAuthenticationFailureWithoutRequestContext(): void
|
||||||
|
{
|
||||||
|
$this->requestContext->clear();
|
||||||
|
$this->store->expects($this->never())->method('createLogOnce');
|
||||||
|
$this->store->expects($this->never())->method('countRecentFailures');
|
||||||
|
|
||||||
|
$this->service->handleAuthFailure(new AuthenticationFailedEvent(
|
||||||
|
reason: 'invalid_credentials',
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[TestDox('Unsafe numeric firewall settings fall back to safe defaults')]
|
#[TestDox('Unsafe numeric firewall settings fall back to safe defaults')]
|
||||||
public function testConfigurationBounds(): void
|
public function testConfigurationBounds(): void
|
||||||
{
|
{
|
||||||
@@ -404,10 +455,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
->with('tenant-a', '203.0.113.10', 300)
|
->with('tenant-a', '203.0.113.10', 300)
|
||||||
->willReturn(0);
|
->willReturn(0);
|
||||||
|
|
||||||
$event = new AuthenticationFailedEvent(
|
$event = new AuthenticationFailedEvent(tenantId: 'tenant-a');
|
||||||
'203.0.113.10',
|
|
||||||
tenantId: 'tenant-a',
|
|
||||||
);
|
|
||||||
$this->service->handleAuthFailure($event);
|
$this->service->handleAuthFailure($event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,10 +508,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$event = new AuthenticationFailedEvent(
|
$event = new AuthenticationFailedEvent(tenantId: 'tenant-event');
|
||||||
'203.0.113.10',
|
|
||||||
tenantId: 'tenant-event',
|
|
||||||
);
|
|
||||||
$this->service->handleAuthFailure($event);
|
$this->service->handleAuthFailure($event);
|
||||||
|
|
||||||
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);
|
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);
|
||||||
@@ -486,7 +531,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->events->expects($this->never())->method('dispatch');
|
$this->events->expects($this->never())->method('dispatch');
|
||||||
|
|
||||||
$this->service->handleAuthFailure(
|
$this->service->handleAuthFailure(
|
||||||
new AuthenticationFailedEvent('203.0.113.10')
|
new AuthenticationFailedEvent()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +545,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
->willReturn(0);
|
->willReturn(0);
|
||||||
|
|
||||||
$this->service->handleAuthFailure(
|
$this->service->handleAuthFailure(
|
||||||
new AuthenticationFailedEvent('203.0.113.10')
|
new AuthenticationFailedEvent()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,7 +557,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->store->expects($this->never())->method('depositRule');
|
$this->store->expects($this->never())->method('depositRule');
|
||||||
|
|
||||||
$this->service->handleAuthFailure(
|
$this->service->handleAuthFailure(
|
||||||
new AuthenticationFailedEvent('203.0.113.10')
|
new AuthenticationFailedEvent()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +571,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
->method('countRecentFailures')
|
->method('countRecentFailures')
|
||||||
->with('tenant-a', '203.0.113.10', 300)
|
->with('tenant-a', '203.0.113.10', 300)
|
||||||
->willReturn(1);
|
->willReturn(1);
|
||||||
$event = new AuthenticationFailedEvent('203.0.113.10');
|
$event = new AuthenticationFailedEvent();
|
||||||
$eventId = $event->getEventId();
|
$eventId = $event->getEventId();
|
||||||
|
|
||||||
$this->service->handleAuthFailure($event);
|
$this->service->handleAuthFailure($event);
|
||||||
|
|||||||
Reference in New Issue
Block a user