Refactored out claim check logic to its own class

This commit is contained in:
Chris 2024-03-14 14:03:27 +01:00
parent e61d0abb5d
commit d8ae8ade70
6 changed files with 71 additions and 26 deletions

View File

@ -0,0 +1,46 @@
<?php
namespace NoccyLabs\Mercureact\Broker\Security;
use NoccyLabs\SimpleJWT\JWTToken;
use Rize\UriTemplate\UriTemplate;
class ClaimChecker
{
private UriTemplate $uriTemplate;
public function __construct()
{
$this->uriTemplate = new UriTemplate();
}
public function matchAll(array $topics, array $claims): bool
{
$matched = 0;
foreach ((array)$topics as $match) {
foreach ($claims as $claim) {
if (($claim === "*")
|| ($claim === $match)
|| ($this->uriTemplate->extract($claim, $match, true))) {
$matched++;
break;
}
}
}
return ($matched == count($topics));
}
public function matchOne(array $topics, array $claims): bool
{
foreach ((array)$topics as $match) {
foreach ($claims as $claim) {
if (($claim === "*")
|| ($claim === $match)
|| ($this->uriTemplate->extract($claim, $match, true))) {
return true;
}
}
}
return false;
}
}

View File

@ -30,6 +30,11 @@ class SseSubscriber implements SubscriberInterface
return $this->request->getAttribute('authorized'); return $this->request->getAttribute('authorized');
} }
public function getMercureClaims(): ?array
{
return $this->request->getAttribute('mercure.claims');
}
public function getPayload(): array public function getPayload(): array
{ {
return $this->request->getAttribute('mercure.payload')??[]; return $this->request->getAttribute('mercure.payload')??[];

View File

@ -21,6 +21,13 @@ interface SubscriberInterface
*/ */
public function isAuthenticated(): bool; public function isAuthenticated(): bool;
/**
* Returns the content of the JWT mercure claim if present.
*
* @return array|null
*/
public function getMercureClaims(): ?array;
/** /**
* Returns the content of the JWT mercure.payload claim if present. * Returns the content of the JWT mercure.payload claim if present.
* *

View File

@ -21,13 +21,8 @@ class WsSubscriber implements SubscriberInterface, EventEmitterInterface
const EVENT_UNSUBSCRIBE = 'unsubscribe'; const EVENT_UNSUBSCRIBE = 'unsubscribe';
const EVENT_ERROR = 'error'; const EVENT_ERROR = 'error';
const STATE_UNAUTHORIZED = 0;
const STATE_AUTHORIZED = 1;
private string $id; private string $id;
private int $state = self::STATE_UNAUTHORIZED;
public function __construct( public function __construct(
private WebSocketConnection $stream, private WebSocketConnection $stream,
private ServerRequestInterface $request, private ServerRequestInterface $request,
@ -73,6 +68,11 @@ class WsSubscriber implements SubscriberInterface, EventEmitterInterface
return $this->token && $this->token->isValid(); return $this->token && $this->token->isValid();
} }
public function getMercureClaims(): ?array
{
return $this->request->getAttribute('mercure.claims');
}
public function getPayload(): array public function getPayload(): array
{ {
return $this->request->getAttribute('mercure.payload')??[]; return $this->request->getAttribute('mercure.payload')??[];

View File

@ -3,6 +3,7 @@
namespace NoccyLabs\Mercureact\Http\Middleware; namespace NoccyLabs\Mercureact\Http\Middleware;
use NoccyLabs\Mercureact\Broker\Message; use NoccyLabs\Mercureact\Broker\Message;
use NoccyLabs\Mercureact\Broker\Security\ClaimChecker;
use NoccyLabs\Mercureact\Broker\Subscriber\SseSubscriber; use NoccyLabs\Mercureact\Broker\Subscriber\SseSubscriber;
use NoccyLabs\Mercureact\Broker\TopicManager; use NoccyLabs\Mercureact\Broker\TopicManager;
use NoccyLabs\Mercureact\Configuration; use NoccyLabs\Mercureact\Configuration;
@ -27,6 +28,8 @@ class MercureHandler
private int $seenIdHistorySize = 100; private int $seenIdHistorySize = 100;
private ClaimChecker $claimChecker;
public function __construct( public function __construct(
private Configuration $config, private Configuration $config,
private TopicManager $topicManager, private TopicManager $topicManager,
@ -35,6 +38,7 @@ class MercureHandler
{ {
$this->loop = $loop ?? Loop::get(); $this->loop = $loop ?? Loop::get();
$this->seenIdHistorySize = $this->config->getDuplicateIdHistorySize(); $this->seenIdHistorySize = $this->config->getDuplicateIdHistorySize();
$this->claimChecker = new ClaimChecker();
} }
/** /**
@ -94,7 +98,7 @@ class MercureHandler
// Grab the JWT token from the requests authorization attribute // Grab the JWT token from the requests authorization attribute
if ($request->getAttribute('authorized')) { if ($request->getAttribute('authorized')) {
$claims = $request->getAttribute('mercure.subscribe'); $claims = $request->getAttribute('mercure.subscribe');
if (!$this->checkTopicClaims($topics, $claims)) { if (!$this->claimChecker->matchAll($topics, $claims)) {
throw new SecurityException( throw new SecurityException(
message: "Insufficient permissions for subscribe", message: "Insufficient permissions for subscribe",
code: SecurityException::ERR_NO_PERMISSION code: SecurityException::ERR_NO_PERMISSION
@ -112,6 +116,7 @@ class MercureHandler
} }
$this->topicManager->subscribe($subscriber, $topics); $this->topicManager->subscribe($subscriber, $topics);
$responseStream->on('close', function () use ($subscriber, $topics) { $responseStream->on('close', function () use ($subscriber, $topics) {
$this->topicManager->unsubscribe($subscriber, $topics); $this->topicManager->unsubscribe($subscriber, $topics);
}); });
@ -156,7 +161,7 @@ class MercureHandler
if ($request->getAttribute('authorized')) { if ($request->getAttribute('authorized')) {
$claims = $request->getAttribute('mercure.publish'); $claims = $request->getAttribute('mercure.publish');
// check topic against publishClaims // check topic against publishClaims
if (!$this->checkTopicClaims($data['topic']??[], $claims)) { if (!$this->claimChecker->matchAll($data['topic']??[], $claims)) {
throw new SecurityException( throw new SecurityException(
message: "Insufficient permissions for publish", message: "Insufficient permissions for publish",
code: SecurityException::ERR_NO_PERMISSION code: SecurityException::ERR_NO_PERMISSION
@ -195,23 +200,4 @@ class MercureHandler
return Response::plaintext($message->id."\n"); return Response::plaintext($message->id."\n");
} }
private function checkTopicClaims(string|array $topic, array $claims): bool
{
$matched = 0;
foreach ((array)$topic as $match) {
foreach ($claims as $claim) {
if (($claim === "*") || ($claim === $match)) {
$matched++;
break;
}
// TODO make sure that UriTemplate parsing works
if ((new UriTemplate())->extract($claim, $match, true)) {
$matched++;
break;
}
}
}
return ($matched == count($topic));
}
} }

View File

@ -56,6 +56,7 @@ abstract class _Subscriber implements SubscriberInterface {
public array $messages = []; public array $messages = [];
public function isAuthenticated():bool { return false; } public function isAuthenticated():bool { return false; }
public function deliver(Message $message):void { $this->messages[] = $message; } public function deliver(Message $message):void { $this->messages[] = $message; }
public function getMercureClaims(): ?array { return []; }
public function getPayload(): ?array { return null; } public function getPayload(): ?array { return null; }
public function getId(): string { return ""; } public function getId(): string { return ""; }
}; };