diff --git a/.env b/.env index 571fcbf..1f0557f 100644 --- a/.env +++ b/.env @@ -6,4 +6,6 @@ REDIS_DATABASE=0 SERVER_HOST=0.0.0.0 SERVER_PORT=8080 -SERVER_WORKERNUM=3 \ No newline at end of file +SERVER_WORKERNUM=1 + +CONFIG_FILE=./config.json \ No newline at end of file diff --git a/README.md b/README.md index 58098ea..0153f97 100644 --- a/README.md +++ b/README.md @@ -115,11 +115,27 @@ That will now run the server ## Advance Profile Feature Support -The current version does not support Advance Profile Features +### Authentication + +| Feature | Static | Dynamic | +|------------|---------|---------| +| Anonymous | ✓ | ✓ | +| Ticket | ✓ | ✓ | +| Wamp-CRA | ✗ | ✗ | +| Wamp-SCRA | ✗ | ✗ | +| Cryptosign | ✗ | ✗ | +| TLS | ✗ | ✗ | +| Cookie | ✗ | ✗ | + +**Additional Authentication** + +- [ ] Add Feature +- [ ] Add checking of role + ## TODOs - [ ] Implement CBOR Serializer https://wamp-proto.org/wamp_bp_latest_ietf.html#name-serializers - [ ] Implement Advance Profile - [ ] Remove Dependencies from Thruway Common -- [ ] Add OpenSwoole Table Adapter as Data Provider \ No newline at end of file +- [ ] Add OpenSwoole Table Adapter as Data Provider diff --git a/bin/server.php b/bin/server.php index 7b39771..3efacaa 100644 --- a/bin/server.php +++ b/bin/server.php @@ -5,10 +5,10 @@ use Octamp\Wamp\Wamp; use Symfony\Component\Dotenv\Dotenv; -require_once __DIR__ . '/../vendor/autoload.php'; +$loader = require_once __DIR__ . '/../vendor/autoload.php'; $env = new Dotenv(); -$env->loadEnv(dirname(__DIR__ . '') . '/.env'); +$env->loadEnv(dirname(__DIR__) . '/.env'); $redisOptions = [ @@ -32,6 +32,20 @@ host: $_ENV['SERVER_HOST'], port: $_ENV['SERVER_PORT'], workerNum: $_ENV['SERVER_WORKERNUM'], + auth: [ + [ + 'method' => 'ticket', + 'type' => 'dynamic', + 'authenticator' => 'testing', + 'authenticator-realm' => 'realm1', + 'realms' => ['realm1'] + ], + [ + 'method' => 'anonymous', + 'type' => 'static', + 'role' => 'auth' + ] + ], ); $wamp = new Wamp($transportConfig, $adapter); diff --git a/src/Auth/AbstractAuthenticator.php b/src/Auth/AbstractAuthenticator.php new file mode 100644 index 0000000..dbcafa3 --- /dev/null +++ b/src/Auth/AbstractAuthenticator.php @@ -0,0 +1,37 @@ +realms = $this->config['realms'] ?? []; + $this->init(); + } + + protected function init(): void + { + // overwrite this method for custom implementation + } + + public function getRealms(): array + { + return $this->realms; + } + + public function supportRealm(string $realmName): bool + { + return empty($this->realms) || in_array('*', $this->realms) || in_array($realmName, $this->realms); + } + + public function canAuthenticate(Session $session, HelloMessage $message, array $methods): bool + { + return in_array($this->getMethod(), $methods) && $this->supportRealm($session->getRealm()->name); + } +} \ No newline at end of file diff --git a/src/Auth/AbstractDynamicAuthenticator.php b/src/Auth/AbstractDynamicAuthenticator.php new file mode 100644 index 0000000..d7aa94a --- /dev/null +++ b/src/Auth/AbstractDynamicAuthenticator.php @@ -0,0 +1,97 @@ +getDetails(); + $args = [ + 'authmethod' => $this->getMethod(), + ]; + if ($helloDetails->authid) { + $args['authid'] = $helloDetails->authid; + } + if (isset($helloDetails->authextra)) { + $args['authextra'] = $helloDetails->authextra; + } + + $procedureName = $this->config['authenticator']; + $realm = $this->realmManager->getRealm($this->config['authenticator-realm']); + $realmSession = $realm->getMetaSession(); + $requestId = IDHelper::incrementSessionWampID($realmSession); + + $callMessage = new CallMessage($requestId, [], $procedureName, [ + $realm->name, + $args['authid'] ?? null, + array_merge($args, $details), + ]); + + $deferred = new Deferred(); + + $connection = $realmSession->getTransport()->getConnection(); + if ($connection instanceof WithEventDispatcherInterface) { + $connection->once('Message:' . Message::MSG_CALL . ':' . $callMessage->getRequestId(), function (MessageEvent $event): void { + $this->realmManager->dispatch($event->session, $event->message); + }); + $connection->once( + 'Message:' . Message::MSG_RESULT . ':' . $callMessage->getRequestId(), + function (MessageEvent $event) use ($connection, $callMessage, $deferred): void { + $connection->removeListenersForEvent('Message:' . Message::MSG_ERROR . ':' .$callMessage->getRequestId()); + /** @var ResultMessage $message */ + $message = $event->message; + $data = $message->getArguments(); + if (empty($data)) { + $deferred->reject([]); + return; + } + + $result = $data[0]; + $success = $result['status'] ?? true; + + if (!$success) { + $deferred->reject($result); + } else { + $deferred->resolve($result); + } + } + ); + $connection->once( + 'Message:' . Message::MSG_ERROR . ':' . $callMessage->getRequestId(), + function (MessageEvent $event) use ($connection, $callMessage, $deferred): void { + $connection->removeListenersForEvent('Message:' . Message::MSG_RESULT . ':' .$callMessage->getRequestId()); + /** @var ErrorMessage $message */ + $message = $event->message; + + $deferred->resolve([ + 'error_uri' => $message->getErrorURI(), + 'error_details' => $message->getDetails(), + ]); + } + ); + } + + $realmSession->sendMessage($callMessage); + + return $deferred->promise(); + } + + public function setRealmManager(RealmManager $realmManager): void + { + $this->realmManager = $realmManager; + } +} \ No newline at end of file diff --git a/src/Auth/AnonymousDynamicAuthenticator.php b/src/Auth/AnonymousDynamicAuthenticator.php new file mode 100644 index 0000000..80cfc68 --- /dev/null +++ b/src/Auth/AnonymousDynamicAuthenticator.php @@ -0,0 +1,64 @@ +sendMessageToAuthenticator($message, []); + return $promise->then(function ($result) { + $authDetails = []; + if (isset($result['authid'])) { + $authDetails['authid'] = $result['authid']; + } + return [ + 'status' => AuthManager::STATUS_NO_CHALLENGE, + 'auth_details' => $authDetails, + 'verify_details' => $result, + 'challenge_details' => [ + 'challenge_method' => $this->getMethod(), + ], + ]; + }, function ($result) { + $response = [ + 'status' => AuthManager::STATUS_FAILURE, + ]; + if (isset($result['error_uri'])) { + $response['error_uri'] = $result['error_uri']; + } + + if (isset($result['error_details'])) { + $response['error_details'] = $result['error_details']; + } + + return $response; + }); + } + + public function processAuthenticate(Session $session, AuthenticateMessage $message): PromiseInterface + { + return new Promise(function (callable $resolve) { + $resolve(['status' => AuthManager::STATUS_SUCCESS]); + }); + } + + public function getMethod(): string + { + return 'anonymous'; + } + + public function setRealmManager(RealmManager $realmManager): void + { + $this->realmManager = $realmManager; + } +} \ No newline at end of file diff --git a/src/Auth/AnonymousStaticAuthenticator.php b/src/Auth/AnonymousStaticAuthenticator.php new file mode 100644 index 0000000..90b89d5 --- /dev/null +++ b/src/Auth/AnonymousStaticAuthenticator.php @@ -0,0 +1,36 @@ + AuthManager::STATUS_NO_CHALLENGE, 'auth_details' => [ + 'authid' => $this->config['authid'] ?? 'anonymous', + 'authrole' => $this->config['role'] ?? 'anonymous', + ]]); + }); + } + + public function getMethod(): string + { + return 'anonymous'; + } + + public function processAuthenticate(Session $session, AuthenticateMessage $message): PromiseInterface + { + return new Promise(function (callable $resolve) { + $resolve(['status' => AuthManager::STATUS_SUCCESS]); + }); + } +} \ No newline at end of file diff --git a/src/Auth/AuthManager.php b/src/Auth/AuthManager.php new file mode 100644 index 0000000..c953746 --- /dev/null +++ b/src/Auth/AuthManager.php @@ -0,0 +1,250 @@ +addAuthenticator($this->generateAuthenticator($auth)); + } catch (\Exception $exception) { + // TODO log exception + } + } + + if (empty($this->authenticators)) { + $this->addAuthenticator(new AnonymousStaticAuthenticator([])); + } + } + + public function addAuthenticator(AuthenticatorInterface $authenticator): void + { + if ($authenticator instanceof WithRealmManagerInterface && $this->realmManager !== null) { + $authenticator->setRealmManager($this->realmManager); + } + $this->authenticators[] = $authenticator; + } + + public function generateAuthenticator(array $data): ?AuthenticatorInterface + { + $class = '\\Octamp\\Wamp\\Auth\\' . ucwords($data['method']) . ucwords($data['type']) . 'Authenticator'; + if (!class_exists($class)) { + throw new \Exception($data['method'] . ' with type "' . $data['type'] . '" is not known authenticator'); + } + + return new $class($data); + } + + public function processHelloMessage(Session $session, HelloMessage $message): void + { + Coroutine::create(function () use ($session, $message) { + $authenticators = $this->getAuthenticators($session, $message); + if (empty($authenticators)) { + $session->abort((object) ['message' => 'No matching authentication method'], ' wamp.error.no_matching_auth_method'); + return; + } + + $errorUri = 'wamp.error.authentication_failed'; + $errorDetails = []; + + $promise = new Promise(function ($resolve) use ($authenticators, $session, $message, $errorUri, $errorDetails) { + $this->processHelloCurrentAuthenticators($authenticators, $session, $message, $errorUri, $errorDetails, $resolve); + }); + $promise->then(function ($result) use ($session, $message) { + [$status,] = $result; + if ($status === self::HELLO_FAIL) { + [, $uri, $details] = $result; + $session->abort((object) $details, $uri); + return; + } + [, $res, $authenticator] = $result; + + $authDetailsRaw = $res['auth_details'] ?? []; + $status = $res['status']; + + $authDetails = new AuthenticationDetails(); + $authDetails->setAuthId($authDetailsRaw['authid']); + $authDetails->setAuthMethod($authenticator->getMethod()); + $authDetails->setAuthenticator($authenticator); + + if (isset($authDetailsRaw['authrole'])) { + $authDetails->addAuthRole($authDetailsRaw['authrole']); + } + if (isset($authDetailsRaw['authroles'])) { + $authDetails->addAuthRole($authDetailsRaw['authroles']); + } + if (isset($authDetailsRaw['authextra'])) { + $authDetails->setAuthExtra($authDetailsRaw['authextra']); + } + $session->setAuthenticationDetails($authDetails); + + if ($status === self::STATUS_CHALLENGE) { + $challengeDetails = $res['challenge_details']; + $authMethod = $challengeDetails['challenge_method']; + $challenge = $challengeDetails['challenge'] ?? []; + + $session->getAuthenticationDetails()->setChallengeDetails($challengeDetails); + $session->getAuthenticationDetails()->setChallenge($challenge); + if (isset($res['verify_details'])) { + $session->getAuthenticationDetails()->setVerificationDetails($res['verify_details']); + } + + $challengeDetails = $session->getAuthenticationDetails()->getChallengeDetails(); + $session->sendMessage(new ChallengeMessage($authMethod, $challengeDetails)); + } elseif ($status === self::STATUS_NO_CHALLENGE) { + $session->setAuthenticated(true); + $details = $session->getAuthenticationDetails()->jsonSerialize(); + // todo update roles for details + $details = array_merge($details, (array) $message->getDetails()); + $session->sendMessage(new WelcomeMessage( + $session->getSessionId(), + $details + )); + } + }); + }); + } + + /** + * @param AuthenticatorInterface[] $authenticators + * @param Session $session + * @param HelloMessage $message + * @return void + */ + public function processHelloCurrentAuthenticators(array &$authenticators, Session $session, HelloMessage $message, string $errorUri, array $errorDetails, callable $resolve): void + { + do { + if (key($authenticators) === null) { + $resolve([self::HELLO_FAIL, $errorUri, $errorDetails]); + break; + } + + $authenticator = current($authenticators); + $promise = $authenticator->processHello($session, $message); + $restPromise = $promise->then(function ($res) use ($resolve, $authenticator, &$authenticators, &$errorUri, &$errorDetails, $session, $message) { + $status = $res['status'] ?? self::STATUS_FAILURE; + if (in_array($status, [self::STATUS_CHALLENGE, self::STATUS_NO_CHALLENGE])) { + $resolve([self::HELLO_SUCCESS, $res, $authenticator]); + + return false; + } else { + $errorUri = $res['error_uri'] ?? $errorUri; + $errorDetails = $res['error_details'] ?? $errorDetails; + next($authenticators); + return true; + } + }); + $result = $restPromise->wait(); + + unset($promise); + unset($restPromise); + } while ($result); + } + + public function processAuthenticateMessage(Session $session, AuthenticateMessage $message): void + { + if ($session->getAuthenticationDetails() === null) { + // TODO log + return; + } + $authenticator = $session->getAuthenticationDetails()->getAuthenticator(); + if ($authenticator === null) { + $session->abort((object) [], 'wamp.error.authentication_failed'); + return; + } + + $errorUri = 'wamp.error.authentication_failed'; + $errorDetails = []; + + $res = $authenticator->processAuthenticate($session, $message); + $status = $res['status'] ?? self::STATUS_FAILURE; + + if ($status !== self::STATUS_SUCCESS) { + $session->abort((object) ($res['error_details'] ?? $errorDetails), $res['error_uri'] ?? $errorUri); + return; + } + + $authDetailsRaw = $res['auth_details'] ?? []; + + if (isset($authDetailsRaw['authid'])) { + $session->getAuthenticationDetails()->setAuthId($authDetailsRaw['authid']); + } + + if (isset($authDetailsRaw['authrole'])) { + $session->getAuthenticationDetails()->addAuthRole($authDetailsRaw['authrole']); + } + if (isset($authDetailsRaw['authroles'])) { + $session->getAuthenticationDetails()->addAuthRole($authDetailsRaw['authroles']); + } + if (isset($authDetailsRaw['authextra'])) { + $session->getAuthenticationDetails()->setAuthExtra($authDetailsRaw['authextra']); + } + if (isset($authDetailsRaw['authprovider'])) { + $session->getAuthenticationDetails()->setAuthProvider($authDetailsRaw['authprovider']); + } + + $session->setAuthenticated(true); + $session->sendMessage(new WelcomeMessage($session->getSessionId(), $session->getAuthenticationDetails()->jsonSerialize())); + } + + /** + * @param Session $session + * @param HelloMessage $helloMessage + * @return AuthenticatorInterface[] + */ + protected function getAuthenticators(Session $session, HelloMessage $helloMessage): array + { + $authenticators = []; + $authMethods = $helloMessage->getDetails()->authmethods ?? ['anonymous']; + foreach ($this->authenticators as $authenticator) { + if ($authenticator->canAuthenticate($session, $helloMessage, $authMethods)) { + $authenticators[] = $authenticator; + } + } + + return $authenticators; + } + + public function setRealmManager(RealmManager $realmManager): void + { + $this->realmManager = $realmManager; + + foreach ($this->authenticators as $authenticator) { + if ($authenticator instanceof WithRealmManagerInterface) { + $authenticator->setRealmManager($this->realmManager); + } + } + } +} \ No newline at end of file diff --git a/src/Auth/AuthenticationDetails.php b/src/Auth/AuthenticationDetails.php new file mode 100644 index 0000000..1753776 --- /dev/null +++ b/src/Auth/AuthenticationDetails.php @@ -0,0 +1,184 @@ +authId = null; + $this->authMethod = null; + $this->challenge = null; + $this->challengeDetails = null; + $this->authExtra = null; + $this->authProvider = null; + $this->authRoles = []; + } + + public function setChallengeDetails(array|object $challengeDetails): void + { + $this->challengeDetails = (object) $challengeDetails; + } + + public function getChallengeDetails(): ?object + { + return $this->challengeDetails; + } + + public function setChallenge(mixed $challenge): void + { + $this->challenge = $challenge; + } + + public function getChallenge(): ?array + { + return $this->challenge; + } + + public function setAuthId(string|int|float|null $authId): void + { + $this->authId = $authId; + } + + public function getAuthId(): string|int|float|null + { + return $this->authId; + } + + public function setAuthMethod(string $authMethod): void + { + $this->authMethod = $authMethod; + } + + public function getAuthMethod(): string + { + return $this->authMethod; + } + + static public function createAnonymous(): AuthenticationDetails + { + $authDetails = new static(); + $authDetails->setAuthId("anonymous"); + $authDetails->setAuthMethod("anonymous"); + $authDetails->addAuthRole("anonymous"); + + return $authDetails; + } + + public function getAuthRoles(): array + { + return $this->authRoles; + } + + public function setAuthRoles(array $authRoles): void + { + $this->authRoles = $authRoles; + } + + public function addAuthRole(string|array $authRole): void + { + if (is_array($authRole)) { + $this->authRoles = array_merge($authRole, $this->authRoles); + } else { + // this is done this way so that most recent addition will be the + // singular role for compatibility + array_unshift($this->authRoles, $authRole); + } + } + + public function hasAuthRole(string $authRole): bool + { + if (in_array($authRole, $this->authRoles)) { + return true; + } else { + return false; + } + } + + public function getAuthRole(): ?string + { + if (count($this->authRoles) > 0) { + return $this->authRoles[0]; + } else { + return null; + } + } + + public function getAuthExtra(): ?object + { + return $this->authExtra; + } + + public function setAuthExtra(array|object $authExtra): void + { + $this->authExtra = (object) $authExtra; + } + + public function setAuthProvider(string $provider): void + { + $this->authProvider = $provider; + } + + public function getAuthProvider(): ?string + { + return $this->authProvider; + } + + public function setAuthenticator(AuthenticatorInterface $authenticator): void + { + $this->authenticator = $authenticator; + } + + public function setVerificationDetails(array|object $details): void + { + $this->verificationDetails = (object) $details; + } + + public function getVerificationDetails(): ?object + { + return $this->verificationDetails; + } + + public function getAuthenticator(): ?AuthenticatorInterface + { + return $this->authenticator; + } + + public function jsonSerialize(): array + { + $details = [ + 'authid' => $this->getAuthId(), + 'authrole' => $this->getAuthRole(), + 'authmethod' => $this->authMethod, + 'authroles' => $this->getAuthRoles(), + ]; + + if ($this->getAuthExtra() !== null) { + $details['authextra'] = $this->getAuthExtra(); + } + + if ($this->getAuthProvider() !== null) { + $details['authprovider'] = $this->getAuthProvider(); + } + + return $details; + } +} \ No newline at end of file diff --git a/src/Auth/AuthenticatorInterface.php b/src/Auth/AuthenticatorInterface.php new file mode 100644 index 0000000..c190132 --- /dev/null +++ b/src/Auth/AuthenticatorInterface.php @@ -0,0 +1,32 @@ +uri, $code, $previous); + } + + public function getUri(): string + { + return $this->uri; + } + + public function getErrorDetails(): array + { + return $this->errorDetails; + } +} \ No newline at end of file diff --git a/src/Auth/TicketDynamicAuthenticator.php b/src/Auth/TicketDynamicAuthenticator.php new file mode 100644 index 0000000..2f43acf --- /dev/null +++ b/src/Auth/TicketDynamicAuthenticator.php @@ -0,0 +1,176 @@ +getDetails(); + $authId = $helloDetails->authid ?? null; + + if ($authId === null) { + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + 'error_uri' => 'wamp.error.authentication_required', + 'error_details' => ['message' => 'authid required'], + ]); + return; + } + + $procedureName = $this->config['authenticator']; + $realm = $this->realmManager->getRealm($this->config['authenticator-realm']); + + $realmSession = $realm->getMetaSession(); + $requestId = IDHelper::incrementSessionWampID($realmSession); + $args = [ + 'authid' => $authId, + 'authmethod' => $this->getMethod(), + ]; + + if (isset($helloDetails->authextra)) { + $args['authextra'] = $helloDetails->authextra; + } + $callMessage = new CallMessage($requestId, [], $procedureName, [ + $realm->name, + $authId, + $args + ]); + + $connection = $realmSession->getTransport()->getConnection(); + if ($connection instanceof WithEventDispatcherInterface) { + $connection->once('Message:' . Message::MSG_CALL . ':' . $callMessage->getRequestId(), function (MessageEvent $event): void { + $this->realmManager->dispatch($event->session, $event->message); + }); + $connection->once( + 'Message:' . Message::MSG_RESULT . ':' . $callMessage->getRequestId(), + function (MessageEvent $event) use ($connection, $callMessage, $resolve, $authId): void { + $connection->removeListenersForEvent('Message:' . Message::MSG_ERROR . ':' .$callMessage->getRequestId()); + /** @var ResultMessage $message */ + $message = $event->message; + $data = $message->getArguments(); + if (empty($data)) { + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + ]); + return; + } + + $result = $data[0]; + $success = $result['status'] ?? true; + + if (!$success) { + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + 'error_uri' => $result['error_uri'] ?? null, + 'error_details' => $result['error_details'] ?? null, + ]); + } else { + $authDetails = []; + if (isset($result['authid'])) { + $authDetails['authid'] = $result['authid']; + } + $resolve([ + 'status' => AuthManager::STATUS_CHALLENGE, + 'auth_details' => $authDetails, + 'verify_details' => $result, + 'challenge_details' => [ + 'challenge_method' => $this->getMethod(), + ], + ]); + } + } + ); + $connection->once( + 'Message:' . Message::MSG_ERROR . ':' . $callMessage->getRequestId(), + function (MessageEvent $event) use ($connection, $callMessage, $resolve): void { + $connection->removeListenersForEvent('Message:' . Message::MSG_RESULT . ':' .$callMessage->getRequestId()); + /** @var ErrorMessage $message */ + $message = $event->message; + + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + 'error_uri' => $message->getErrorURI(), + 'error_details' => $message->getDetails(), + ]); + } + ); + } + + $realmSession->sendMessage($callMessage); + }); + } + + public function processAuthenticate(Session $session, AuthenticateMessage $message): PromiseInterface + { + return new Promise(function (callable $resolve) use ($session, $message) : void { + $verificationDetails = $session->getAuthenticationDetails()->getVerificationDetails(); + if ($verificationDetails === null) { + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + 'error_uri' => 'wamp.error.authentication_denied', + 'error_details' => ['message' => 'Invalid ticket / signature'], + ]); + return; + } + $ticket = $verificationDetails->ticket ?? null; + if ($ticket !== $message->getSignature()) { + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + 'error_uri' => 'wamp.error.authentication_denied', + 'error_details' => ['message' => 'Invalid ticket / signature'], + ]); + return; + } + + $authDetails = [ + 'authid' => $verificationDetails->authid, + ]; + if ($verificationDetails->role) { + $authDetails['authrole'] = $verificationDetails->role; + } + if ($verificationDetails->authextra) { + $authDetails['authextra'] = $verificationDetails->extra; + } + if ($verificationDetails->role) { + $authDetails['authprovider'] = $verificationDetails->authprovider; + } + $resolve([ + 'status' => AuthManager::STATUS_SUCCESS, + 'auth_details' => $authDetails, + ]); + }); + } + + public function getMethod(): string + { + return 'ticket'; + } + + public function setRealmManager(RealmManager $realmManager): void + { + $this->realmManager = $realmManager; + } +} \ No newline at end of file diff --git a/src/Auth/TicketStaticAuthenticator.php b/src/Auth/TicketStaticAuthenticator.php new file mode 100644 index 0000000..ec55335 --- /dev/null +++ b/src/Auth/TicketStaticAuthenticator.php @@ -0,0 +1,121 @@ +config); + + $principals = $this->config['principals'] ?? []; + + $maxAuthIdLen = max(array_column($principals, 'authid')); + $maxSecretLen = max(array_column($principals, 'ticket')); + $maxRoleLen = max(array_column($principals, 'role')); + + $this->table = new Table(count($principals)); + $this->table->column('authid', Table::TYPE_STRING, $maxAuthIdLen); + $this->table->column('ticket', Table::TYPE_STRING, $maxSecretLen); + $this->table->column('role', Table::TYPE_STRING, $maxRoleLen); + $this->table->create(); + + foreach ($principals as $principal) { + $this->table->set($principal['authid'], $principal); + } + } + + public function processHello(Session $session, HelloMessage $message): PromiseInterface + { + return new Promise(function (callable $resolve) use ($message) : void{ + $helloDetails = $message->getDetails(); + $authId = $helloDetails->authid ?? null; + if ($authId === null) { + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + 'error_uri' => 'wamp.error.authentication_required', + 'error_details' => ['message' => 'authid required'], + ]); + return; + } + + if (!$this->table->exists($authId)) { + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + 'error_uri' => 'wamp.error.no_such_principal', + 'error_details' => ['message' => 'authid "' . $authId . '" does not exists'], + ]); + + return; + } + + + $resolve([ + 'status' => AuthManager::STATUS_CHALLENGE, + 'auth_details' => [ + 'authid' => $authId, + ], + 'challenge_details' => [ + 'challenge_method' => $this->getMethod(), + ], + ]); + }); + } + + public function processAuthenticate(Session $session, AuthenticateMessage $message): PromiseInterface + { + return new Promise(function (callable $resolve) use ($session, $message) : void { + $authId = $session->getAuthenticationDetails()->getAuthId(); + if ($authId === null) { + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + 'error_uri' => 'wamp.error.authentication_required', + 'error_details' => ['message' => 'authid required'], + ]); + return; + } + + if (!$this->table->exists($authId)) { + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + 'error_uri' => 'wamp.error.no_such_principal', + 'error_details' => ['message' => 'authid "' . $authId . '" does not exists'], + ]); + return; + } + + $principal = $this->table->get($authId); + if ($principal['ticket'] !== $message->getSignature()) { + $resolve([ + 'status' => AuthManager::STATUS_FAILURE, + 'error_uri' => 'wamp.error.authentication_denied', + 'error_details' => ['message' => 'Invalid ticket / signature'], + ]); + return; + } + + $resolve([ + 'status' => AuthManager::STATUS_SUCCESS, + 'auth_details' => [ + 'authid' => $authId, + 'authrole' => $principal['role'], + ], + ]); + }); + } + + public function getMethod(): string + { + return 'ticket'; + } +} \ No newline at end of file diff --git a/src/Auth/WithRealmManagerInterface.php b/src/Auth/WithRealmManagerInterface.php new file mode 100644 index 0000000..67843db --- /dev/null +++ b/src/Auth/WithRealmManagerInterface.php @@ -0,0 +1,10 @@ +eventDispatcher = new EventDispatcher(); + } + + public function send(array|string|null $data, int $opcode = Server::WEBSOCKET_OPCODE_TEXT): void + { + $this->dispatch('SendMessage', new SendMessageEvent($data, $opcode)); + } + + public function on(string $eventName, callable $listener, int $priority = 0): void + { + $this->eventDispatcher->addListener($eventName, $listener, $priority); + } + + public function once(string $eventName, callable $listener, int $priority = 0): void + { + $this->eventDispatcher->addListenerOnce($eventName, $listener, $priority); + } + + public function dispatch(string $eventName, ?Event $event): Event + { + return $this->eventDispatcher->dispatch($eventName, $event); + } + + public function removeListener(string $eventName, callable $listener): void + { + $this->eventDispatcher->removeListener($eventName, $listener); + } + + public function removeListenersForEvent(string $eventName): void + { + $this->eventDispatcher->removeListenersForEvent($eventName); + } +} \ No newline at end of file diff --git a/src/Connection/Event/SendMessageEvent.php b/src/Connection/Event/SendMessageEvent.php new file mode 100644 index 0000000..08574e1 --- /dev/null +++ b/src/Connection/Event/SendMessageEvent.php @@ -0,0 +1,13 @@ +setDispatcher($this); + $event->setName($eventName); + + if ($listeners = $this->getListeners($eventName)) { + $this->doDispatch($listeners, $eventName, $event); + $this->removeListenerCalledOnce($eventName, $listeners); + } + + return $event; + } + + /** + * @param string $eventName + * @param Listener[] $listeners + * @return void + */ + protected function removeListenerCalledOnce(string $eventName, array $listeners): void + { + foreach ($listeners as $listener) { + if ($listener->shouldRemove()) { + $this->removeListener($eventName, $listener); + } + } + } + + /** + * {@inheritdoc} + */ + public function getListeners($eventName = null) + { + if (null !== $eventName) { + if (!isset($this->listeners[$eventName])) { + return array(); + } + + if (!isset($this->sorted[$eventName])) { + $this->sortListeners($eventName); + } + + return $this->sorted[$eventName]; + } + + foreach ($this->listeners as $eventName => $eventListeners) { + if (!isset($this->sorted[$eventName])) { + $this->sortListeners($eventName); + } + } + + return array_filter($this->sorted); + } + + /** + * Gets the listener priority for a specific event. + * + * Returns null if the event or the listener does not exist. + * + * @param string $eventName The name of the event + * @param callable $listener The listener + * + * @return int|null The event listener priority + */ + public function getListenerPriority($eventName, $listener) + { + if (!isset($this->listeners[$eventName])) { + return; + } + + foreach ($this->listeners[$eventName] as $priority => $listeners) { + if (false !== \in_array($listener, $listeners, true)) { + return $priority; + } + } + } + + /** + * {@inheritdoc} + */ + public function hasListeners($eventName = null) + { + return (bool) $this->getListeners($eventName); + } + + /** + * {@inheritdoc} + */ + public function addListener($eventName, $listener, $priority = 0) + { + $this->listeners[$eventName][$priority][] = new Listener($listener, false); + unset($this->sorted[$eventName]); + } + + public function addListenerOnce(string $eventName, callable $listener, int $priority = 0): void + { + $this->listeners[$eventName][$priority][] = new Listener($listener, true); + unset($this->sorted[$eventName]); + } + + /** + * {@inheritdoc} + */ + public function removeListener($eventName, $listener) + { + if (!isset($this->listeners[$eventName])) { + return; + } + + foreach ($this->listeners[$eventName] as $priority => $listeners) { + if (false !== ($key = array_search($listener, $listeners, true))) { + unset($this->listeners[$eventName][$priority][$key], $this->sorted[$eventName]); + } + } + } + + public function removeListenersForEvent(string $eventName): void + { + if (isset($this->listeners[$eventName])) { + unset($this->listeners[$eventName]); + } + } + + /** + * {@inheritdoc} + */ + public function addSubscriber(EventSubscriberInterface $subscriber) + { + foreach ($subscriber->getSubscribedEvents() as $eventName => $params) { + if (\is_string($params)) { + $this->addListener($eventName, array($subscriber, $params)); + } elseif (\is_string($params[0])) { + $this->addListener($eventName, array($subscriber, $params[0]), isset($params[1]) ? $params[1] : 0); + } else { + foreach ($params as $listener) { + $this->addListener($eventName, array($subscriber, $listener[0]), isset($listener[1]) ? $listener[1] : 0); + } + } + } + } + + /** + * {@inheritdoc} + */ + public function removeSubscriber(EventSubscriberInterface $subscriber) + { + foreach ($subscriber->getSubscribedEvents() as $eventName => $params) { + if (\is_array($params) && \is_array($params[0])) { + foreach ($params as $listener) { + $this->removeListener($eventName, array($subscriber, $listener[0])); + } + } else { + $this->removeListener($eventName, array($subscriber, \is_string($params) ? $params : $params[0])); + } + } + } + + /** + * Triggers the listeners of an event. + * + * This method can be overridden to add functionality that is executed + * for each listener. + * + * @param callable[] $listeners The event listeners + * @param string $eventName The name of the event to dispatch + * @param Event $event The event object to pass to the event handlers/listeners + */ + protected function doDispatch($listeners, $eventName, Event $event) + { + foreach ($listeners as $listener) { + if ($event->isPropagationStopped()) { + break; + } + \call_user_func($listener, $event, $eventName, $this); + } + } + + /** + * Sorts the internal list of listeners for the given event by priority. + * + * @param string $eventName The name of the event + */ + private function sortListeners($eventName) + { + krsort($this->listeners[$eventName]); + $this->sorted[$eventName] = \call_user_func_array('array_merge', $this->listeners[$eventName]); + } +} \ No newline at end of file diff --git a/src/EventDispatcher/EventDispatcherInterface.php b/src/EventDispatcher/EventDispatcherInterface.php new file mode 100644 index 0000000..77c0e79 --- /dev/null +++ b/src/EventDispatcher/EventDispatcherInterface.php @@ -0,0 +1,9 @@ +calledOnce && $this->once; + } + + public function __invoke(Event $event) + { + $listener = $this->listener; + if (is_callable($listener)) { + if ($this->once && $this->calledOnce) { + return; + } + $this->calledOnce = true; + $listener($event); + } + } +} \ No newline at end of file diff --git a/src/Promise/Deferred.php b/src/Promise/Deferred.php new file mode 100644 index 0000000..1afa321 --- /dev/null +++ b/src/Promise/Deferred.php @@ -0,0 +1,34 @@ +promise === null) { + $this->promise = new Promise(function ($resolve, $reject) { + $this->resolveCallback = $resolve; + $this->rejectCallback = $reject; + }); + } + + return $this->promise; + } + + public function resolve(mixed $value = null): void + { + $this->promise(); + call_user_func($this->resolveCallback, $value); + } + + public function reject(mixed $reason): void + { + $this->promise(); + call_user_func($this->rejectCallback, $reason); + } +} \ No newline at end of file diff --git a/src/Promise/Deffered.php b/src/Promise/Deffered.php deleted file mode 100644 index 89d05be..0000000 --- a/src/Promise/Deffered.php +++ /dev/null @@ -1,8 +0,0 @@ -setResult($value); + $this->setState(self::STATE_FULFILLED); + } + + public function processReject(mixed $value = null): void + { + if ($this->isPending()) { + $this->setResult($value); + $this->setState(self::STATE_REJECTED); + } + } + + public function then(?callable $onFulfilled = null, ?callable $onRejected = null): static + { + return self::create(function (callable $resolve, callable $reject) use ($onFulfilled, $onRejected) { + while ($this->isPending()) { + // @codeCoverageIgnoreStart + Coroutine::usleep(1); + // @codeCoverageIgnoreEnd + } + $callable = $this->isFulfilled() ? $onFulfilled : $onRejected; + if (!is_callable($callable)) { + $resolve($this->result); + return; + } + try { + $resolve($callable($this->result)); + } catch (\Throwable $error) { + $reject($error); + } + }); + } + + final public function catch(callable $onRejected): static + { + return $this->then(null, $onRejected); + } + + final public function wait(): mixed + { + while ($this->isPending()) { + Coroutine::usleep(1); + } + + return $this->result; + } + + final public static function create(callable $promise): static + { + return new static($promise); + } + + final protected function setState(int $state): void + { + $this->state = $state; + } + + final protected function isPending(): bool + { + return $this->state == self::STATE_PENDING; + } + + final protected function isFulfilled(): bool + { + return $this->state == self::STATE_FULFILLED; + } + private function setResult(mixed $value): void + { + if ($value instanceof PromiseInterface) { + $resolved = false; + $callable = function ($value) use (&$resolved) { + $this->setResult($value); + $resolved = true; + }; + $value->then($callable, $callable); + // resolve async locking error + while (!$resolved) { + Coroutine::usleep(1); + } + } else { + $this->result = $value; + } + } } \ No newline at end of file diff --git a/src/Promise/PromiseInterface.php b/src/Promise/PromiseInterface.php index 5bd59bb..8eefa15 100644 --- a/src/Promise/PromiseInterface.php +++ b/src/Promise/PromiseInterface.php @@ -4,5 +4,23 @@ interface PromiseInterface { + public function then(?callable $onFulfilled = null, ?callable $onRejected = null): static; + public function wait(): mixed; + + /** + * This method return a promise with rejected case only + * + * @param callable $onRejected + * @return PromiseInterface + */ + public function catch(callable $onRejected): static; + + /** + * This method create new promise instance + * + * @param callable $promise + * @return PromiseInterface + */ + public static function create(callable $promise): static; } \ No newline at end of file diff --git a/src/Realm/Realm.php b/src/Realm/Realm.php index 71c292c..3dd104d 100644 --- a/src/Realm/Realm.php +++ b/src/Realm/Realm.php @@ -2,24 +2,26 @@ namespace Octamp\Wamp\Realm; +use Octamp\Server\Connection\Connection; +use Octamp\Wamp\Auth\AuthManager; use Octamp\Wamp\Event\EventInterface; use Octamp\Wamp\Event\LeaveRealmEvent; use Octamp\Wamp\Peers\Router; use Octamp\Wamp\Session\Session; use Octamp\Wamp\Session\SessionStorage; -use Octamp\Wamp\Transport\DummyTransport; -use Thruway\Authentication\AuthenticationDetails; use Thruway\Common\Utils; +use Thruway\Message\AuthenticateMessage; use Thruway\Message\HelloMessage; use Thruway\Message\Message; use Thruway\Message\PublishMessage; -use Thruway\Message\WelcomeMessage; class Realm { private ?Session $metaSession = null; - public function __construct(public readonly string $name, protected SessionStorage $sessionStorage, protected Router $router) + private ?Connection $connection = null; + + public function __construct(public readonly string $name, protected SessionStorage $sessionStorage, protected Router $router, protected AuthManager $authManager) { } @@ -43,13 +45,22 @@ public function handle(Session $session, Message|EventInterface $message): void public function onHelloMessage(Session $session, HelloMessage $message): void { if ($session->isAuthenticated()) { + // TODO log return; } - $session->setAuthenticationDetails(AuthenticationDetails::createAnonymous()); - $session->setAuthenticated(true); - $welcome = new WelcomeMessage($session->getId(), $message->getDetails()); - $session->sendMessage($welcome); + $this->authManager->processHelloMessage($session, $message); + $this->sessionStorage->saveSession($session); + } + + public function onAuthenticateMessage(Session $session, AuthenticateMessage $message): void + { + if ($session->isAuthenticated()) { + // TODO log + return; + } + + $this->authManager->processAuthenticateMessage($session, $message); $this->sessionStorage->saveSession($session); } @@ -64,11 +75,7 @@ public function onLeaveRealmEvent(Session $session, LeaveRealmEvent $event): voi public function publishMeta(string $topicName, array $arguments, ?object $argumentsKw = null, ?object $options = null): void { - if ($this->metaSession === null) { - $this->metaSession = $this->sessionStorage->createDummy(); - } - - $this->handle($this->metaSession, new PublishMessage( + $this->handle($this->getMetaSession(), new PublishMessage( Utils::getUniqueId(), $options, $topicName, @@ -76,4 +83,20 @@ public function publishMeta(string $topicName, array $arguments, ?object $argume $argumentsKw )); } + + public function getMetaSession(): Session + { + if ($this->metaSession === null) { + $this->metaSession = $this->sessionStorage->createDummy($this->connection); + $this->metaSession->setTrusted(true); + $this->addSession($this->metaSession); + } + + return $this->metaSession; + } + + public function setConnection(Connection $connection): void + { + $this->connection = $connection; + } } \ No newline at end of file diff --git a/src/Realm/RealmManager.php b/src/Realm/RealmManager.php index 9ecc40b..038ad7c 100644 --- a/src/Realm/RealmManager.php +++ b/src/Realm/RealmManager.php @@ -3,6 +3,7 @@ namespace Octamp\Wamp\Realm; use Octamp\Wamp\Adapter\AdapterInterface; +use Octamp\Wamp\Auth\AuthManager; use Octamp\Wamp\Peers\Router; use Octamp\Wamp\Session\Session; use Octamp\Wamp\Session\SessionStorage; @@ -17,8 +18,9 @@ class RealmManager protected ?SessionStorage $sessionStorage = null; protected ?AdapterInterface $adapter = null; - public function __construct() + public function __construct(protected AuthManager $authManager) { + $this->authManager->setRealmManager($this); } public function init(SessionStorage $sessionStorage, AdapterInterface $adapter): void @@ -29,7 +31,7 @@ public function init(SessionStorage $sessionStorage, AdapterInterface $adapter): public function createRealm(string $name, Router $router): Realm { - return new Realm($name, $this->sessionStorage, $router); + return new Realm($name, $this->sessionStorage, $router, $this->authManager); } public function addRealm(Realm $realm): void @@ -77,12 +79,11 @@ public function dispatch(Session $session, Message $message): void } if ($session->getRealm() === null) { - $session->abort((object) ['message' => 'the real does not exists'], 'wamp.error.no_such_realm'); + $session->abort((object) ['message' => 'the realm does not exists'], 'wamp.error.no_such_realm'); return; } - $realm = $session->getRealm(); - $realm->handle($session, $message); + $session->getRealm()->handle($session, $message); } public function onHelloMessage(Session $session, HelloMessage $message): void diff --git a/src/Roles/Dealer.php b/src/Roles/Dealer.php index 88c396e..c1e726b 100644 --- a/src/Roles/Dealer.php +++ b/src/Roles/Dealer.php @@ -198,7 +198,7 @@ public function onErrorMessage(Session $session, ErrorMessage $message) protected function processInvocationError(Session $session, ErrorMessage $message) { $key = Registration::generateKeyForInvocation('*', $session->getSessionId(), '*', $message->getRequestId()); - $details = $this->adapter->get($key); + $details = $this->adapter->findOne($key); if ($details === null) { $session->sendMessage(ErrorMessage::createErrorMessageFromMessage($message, 'wamp.error.no_such_procedure')); return; diff --git a/src/Session/Event/MessageEvent.php b/src/Session/Event/MessageEvent.php new file mode 100644 index 0000000..76ecbec --- /dev/null +++ b/src/Session/Event/MessageEvent.php @@ -0,0 +1,15 @@ +transport->getConnection(); + if ($connection instanceof WithEventDispatcherInterface) { + $connection->on('SendMessage', [$this, 'onSendMessage']); + } + } + + public function onSendMessage(SendMessageEvent $event): void + { + $connection = $this->transport->getConnection(); + if (!($connection instanceof WithEventDispatcherInterface)) { + return; + } + if ($event->opcode !== \OpenSwoole\WebSocket\Server::WEBSOCKET_OPCODE_TEXT && $event->opcode !== \OpenSwoole\WebSocket\Server::WEBSOCKET_OPCODE_BINARY) { + return; + } + $message = $this->getTransport()->getSerializer()->deserialize($event->data); + + $eventName = 'Message:' . $message->getMsgCode(); + if (method_exists($message, 'getRequestId')) { + $eventName .= ':' . $message->getRequestId(); + } + $connection->dispatch($eventName, new MessageEvent($this, $message)); } public function setId(string $id): void diff --git a/src/Session/SessionStorage.php b/src/Session/SessionStorage.php index 20b6303..c487cae 100644 --- a/src/Session/SessionStorage.php +++ b/src/Session/SessionStorage.php @@ -2,6 +2,7 @@ namespace Octamp\Wamp\Session; +use Octamp\Server\Connection\Connection; use Octamp\Server\Connection\ConnectionStorage; use Octamp\Wamp\Helper\SerializerHelper; use Octamp\Wamp\Realm\RealmManager; @@ -36,9 +37,12 @@ public function createSession(AbstractTransport $transport, ?string $serverId = return $session; } - public function createDummy(): Session + public function createDummy(Connection $connection): Session { - return new Session(new DummyTransport(), $this->serverId, $this->adapter); + $transport = new OctampTransport($connection); + $transport->setSerializer(new JsonSerializer()); + + return $this->createSession($transport, $this->serverId); } public function createFromArray(array $data): ?Session diff --git a/src/Wamp.php b/src/Wamp.php index 051cdff..ae7116c 100644 --- a/src/Wamp.php +++ b/src/Wamp.php @@ -7,7 +7,9 @@ use Octamp\Server\Generator\RedisIDGenerator; use Octamp\Server\Server; use Octamp\Wamp\Adapter\AdapterInterface; +use Octamp\Wamp\Auth\AuthManager; use Octamp\Wamp\Config\TransportProviderConfig; +use Octamp\Wamp\Connection\DummyConnection; use Octamp\Wamp\Helper\IDHelper; use Octamp\Wamp\Helper\SerializerHelper; use Octamp\Wamp\Peers\Router; @@ -24,10 +26,13 @@ use OpenSwoole\Http\Request; use OpenSwoole\Http\Response; use OpenSwoole\WebSocket\Frame; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; class Wamp { private RealmManager $realmManager; + private AuthManager $authManager; /** * @var TransportProviderInterface[] @@ -36,9 +41,13 @@ class Wamp protected string $serverId; + protected EventDispatcherInterface $eventDispatcher; + public function __construct(private readonly TransportProviderConfig $config, private readonly AdapterInterface $adapter) { - $this->realmManager = new RealmManager(); + $this->eventDispatcher = new EventDispatcher(); + $this->authManager = new AuthManager($this->config->auth, $this->eventDispatcher); + $this->realmManager = new RealmManager($this->authManager); $this->serverId = uniqid(''); $this->init(); } @@ -69,7 +78,23 @@ public function init(): void $router->addTransportProviders($this->transportProviders); + $connection = DummyConnection::createFromArray([ + 'server' => $this->serverId, + 'request' => [ + 'fd' => 0, + 'header' => [], + 'server' => [], + 'cookie' => [], + 'get' => [], + 'files' => [], + 'post' => [], + 'tmpfiles' => [], + ] + ], $server); + $server->getConnectionStorage()->save($connection); $realm = $this->realmManager->createRealm('realm1', $router); + $realm->setConnection($connection); + $realm->getMetaSession(); $this->realmManager->addRealm($realm); });