diff --git a/DEVELOPING.md b/DEVELOPING.md index 58fa540..6f9d1a5 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -108,7 +108,7 @@ You can send custom events from a nextcloud app using the methods provided by `O ```php // in a real app, you'll want to setup DI to get an instance of `IQueue` -$queue = \OC::$server->get(OCA\NotifyPush\IQueue::class); +$queue = \OCP\Server::get(OCA\NotifyPush\Queue\IQueue::class); $queue->push('notify_custom', [ 'user' => "uid", 'message' => "my_message_type", @@ -127,6 +127,105 @@ listen('my_message_type', (message_type, optional_body) => { }) ``` +## Anonymous sessions + +Sometimes you want to push messages to a client that doesn't have a user session, such as a visitor of a public share +or a device that is still going through some kind of pairing flow. + +For these cases your app can create an "anonymous session". Creating a session gives you two values: + +- an **id**, which your app keeps server side and uses to address the session +- a **token**, which you hand to the client and which the client uses to connect to the push server + +Anonymous sessions live in a separate namespace from users, an anonymous session can never receive the messages of a +user (or of another session) and only receives the messages your app explicitly sends to it. In particular it does +*not* receive any of the built in `notify_file`, `notify_activity` or `notify_notification` messages. + +### Creating a session + +```php +// in a real app, you'll want to setup DI to get an instance of `IAnonymousSessionManager` +$sessionManager = \OCP\Server::get(OCA\NotifyPush\IAnonymousSessionManager::class); + +$session = $sessionManager->createSession('myapp'); + +$session->getId(); // "myapp:xIhK1i..." keep this, you need it to send messages +$session->getToken(); // "eyJpZCI6..." hand this to the client +$session->getExpiration(); // unix timestamp after which the client can no longer connect +``` + +Sessions are not stored server side, the id and expiration date are encoded in the token itself and signed with an +instance wide key. This means the client can keep reconnecting with the same token until it expires, but also that an +individual session can not be revoked before it expires. Because of that the lifetime is kept short: the `ttl` (second +argument of `createSession`) is one hour by default and can not be more than 24 hours. + +If a client needs to keep listening for longer than that, issue it a new token for the same session instead of +creating a new session, so the id your app stored stays valid: + +```php +$session = $sessionManager->renewToken($sessionId); +``` + +The previous token is not invalidated by this, it keeps working until it expires on its own. + +### Sending messages to a session + +```php +$sessionManager->send($sessionId, 'my_message_type', ['foo' => 'bar']); +``` + +Which is delivered to the client exactly like a custom event for a user, as `'my_message_type {"foo":"bar"}'`. + +Messages sent while no client is connected with the session are dropped, there is no buffering. + +Session ids are prefixed with the id of the app that created them to keep apps from accidentally addressing each +others sessions. Note that this is a convention and not a security boundary, every app on the server can push to the +message queue directly and therefore to any session. Don't send data over a session of another app, and don't rely on +other apps not being able to send to yours. + +### Connecting from the client + +The client only needs the token, everything else is discovered from the capabilities. Note that the +`ocs/v2.php/cloud/capabilities` request can be made without authentication. + +- Get the `websocket` and `anon_pre_auth` endpoints from the ocs capabilities request +- `POST` the token to the `anon_pre_auth` endpoint as `token`, a short lived pre-authentication token is returned +- Open the websocket +- Send an empty string as username over the websocket +- Send the pre-authentication token as password +- On disconnect, request a new pre-authentication token and connect again + +```javascript +async function connect(nextcloud_url, session_token) { + let capabilities = await fetch(`${nextcloud_url}/ocs/v2.php/cloud/capabilities`, { + headers: {'Accept': 'application/json', 'OCS-APIREQUEST': 'true'}, + }) + .then(response => response.json()) + .then(json => json.ocs.data.capabilities.notify_push); + + let body = new FormData(); + body.set('token', session_token); + let response = await fetch(capabilities.endpoints.anon_pre_auth, {method: 'POST', body}); + if (!response.ok) { + throw new Error('session token is no longer valid'); + } + let pre_auth_token = await response.text(); + + let ws = new WebSocket(capabilities.endpoints.websocket); + ws.onopen = () => { + ws.send(""); + ws.send(pre_auth_token); + }; + ws.onmessage = (msg) => { + console.log(msg.data); + }; + return ws; +} +``` + +The pre-authentication token is single use and only valid for 15 seconds, so it has to be requested again for every +(re)connect. The session token itself stays valid until it expires. + ## Building The server binary is built using rust and cargo, and requires a minimum of rust `1.94`. diff --git a/appinfo/routes.php b/appinfo/routes.php index 0b79359..e05c091 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -32,5 +32,10 @@ 'name' => 'Auth#getUid', 'url' => '/uid', ], + [ + 'name' => 'AnonymousAuth#preAuth', + 'url' => '/anon_pre_auth', + 'verb' => 'POST', + ], ], ]; diff --git a/lib/AnonymousSession.php b/lib/AnonymousSession.php new file mode 100644 index 0000000..871b484 --- /dev/null +++ b/lib/AnonymousSession.php @@ -0,0 +1,48 @@ +id; + } + + /** + * The token the client needs to connect to the push server, hand this to the client. + * + * The token is a secret, anyone holding it can receive the messages sent to this session. + */ + public function getToken(): string { + return $this->token; + } + + /** + * Unix timestamp after which the token stops working and the client can no longer (re)connect + */ + public function getExpiration(): int { + return $this->expiration; + } +} diff --git a/lib/AnonymousSessionManager.php b/lib/AnonymousSessionManager.php new file mode 100644 index 0000000..f277eed --- /dev/null +++ b/lib/AnonymousSessionManager.php @@ -0,0 +1,145 @@ +random->generate(self::SESSION_ID_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC); + + return $this->renewToken($id, $ttl); + } + + #[\Override] + public function renewToken(string $sessionId, int $ttl = self::DEFAULT_TTL): AnonymousSession { + if (preg_match(self::SESSION_ID_PATTERN, $sessionId) !== 1) { + throw new \InvalidArgumentException('Invalid session id: ' . $sessionId); + } + if ($ttl <= 0 || $ttl > self::MAX_TTL) { + throw new \InvalidArgumentException('Session ttl needs to be between 1 and ' . self::MAX_TTL . ' seconds'); + } + + $expiration = $this->timeFactory->getTime() + $ttl; + + return new AnonymousSession($sessionId, $this->createToken($sessionId, $expiration), $expiration); + } + + #[\Override] + public function send(string $sessionId, string $message, mixed $body = null): void { + $this->queue->push('notify_custom', [ + 'session' => $sessionId, + 'message' => $message, + 'body' => $body, + ]); + } + + /** + * Get the id of the session a token belongs to, or null if the token is invalid or expired. + */ + public function validateToken(string $token): ?string { + $parts = explode('.', $token); + if (count($parts) !== 2) { + return null; + } + [$payload, $signature] = $parts; + + if (!hash_equals($this->sign($payload), $signature)) { + return null; + } + + $decoded = json_decode($this->decode($payload), true); + if (!is_array($decoded) || !isset($decoded['id'], $decoded['exp']) + || !is_string($decoded['id']) || !is_int($decoded['exp'])) { + return null; + } + if ($decoded['exp'] <= $this->timeFactory->getTime()) { + return null; + } + + return $decoded['id']; + } + + /** + * Announce a session to the push server and get a short lived token the client can use + * to authenticate a websocket connection. + */ + public function preAuthenticate(string $sessionId): string { + $token = $this->random->generate(self::PRE_AUTH_TOKEN_LENGTH); + + $this->queue->push('notify_pre_auth', [ + 'session' => $sessionId, + 'token' => $token, + ]); + + return $token; + } + + private function createToken(string $id, int $expiration): string { + $payload = $this->encode(json_encode([ + 'id' => $id, + 'exp' => $expiration, + ], JSON_THROW_ON_ERROR)); + + return $payload . '.' . $this->sign($payload); + } + + private function sign(string $payload): string { + return $this->encode(hash_hmac('sha256', $payload, $this->getSigningKey(), true)); + } + + private function getSigningKey(): string { + $key = $this->appConfig->getValueString(Application::APP_ID, self::SIGNING_KEY_CONFIG_KEY); + if ($key === '') { + $key = $this->random->generate(self::SIGNING_KEY_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC); + $this->appConfig->setValueString(Application::APP_ID, self::SIGNING_KEY_CONFIG_KEY, $key, sensitive: true); + } + return $key; + } + + private function encode(string $data): string { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function decode(string $data): string { + return (string)base64_decode(strtr($data, '-_', '+/'), true); + } +} diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index a389f68..ad8a22c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -8,8 +8,10 @@ namespace OCA\NotifyPush\AppInfo; +use OCA\NotifyPush\AnonymousSessionManager; use OCA\NotifyPush\Capabilities; use OCA\NotifyPush\CSPListener; +use OCA\NotifyPush\IAnonymousSessionManager; use OCA\NotifyPush\Listener; use OCA\NotifyPush\Queue\IQueue; use OCA\NotifyPush\Queue\NullQueue; @@ -40,6 +42,8 @@ public function __construct() { public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); + $context->registerServiceAlias(IAnonymousSessionManager::class, AnonymousSessionManager::class); + $context->registerService(IQueue::class, function (ContainerInterface $c) { /** @var PushRedisFactory $factory */ $factory = $c->get(PushRedisFactory::class); diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 66c62fb..352ddd4 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -8,37 +8,51 @@ namespace OCA\NotifyPush; -use OCP\Capabilities\ICapability; +use OCP\Capabilities\IPublicCapability; use OCP\IConfig; use OCP\IURLGenerator; +use OCP\IUserSession; -class Capabilities implements ICapability { +class Capabilities implements IPublicCapability { private $config; private $urlGenerator; + private $userSession; - public function __construct(IConfig $config, IURLGenerator $urlGenerator) { + public function __construct(IConfig $config, IURLGenerator $urlGenerator, IUserSession $userSession) { $this->config = $config; $this->urlGenerator = $urlGenerator; + $this->userSession = $userSession; } public function getCapabilities() { $baseEndpoint = $this->config->getAppValue('notify_push', 'base_endpoint'); + if (!$baseEndpoint) { + return []; + } + $wsEndpoint = str_replace('https://', 'wss://', $baseEndpoint); $wsEndpoint = str_replace('http://', 'ws://', $wsEndpoint) . '/ws'; - if ($baseEndpoint) { - return [ - 'notify_push' => [ - 'type' => ['files', 'activities', 'notifications'], - 'endpoints' => [ - 'websocket' => $wsEndpoint, - 'pre_auth' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('notify_push.Auth.preAuth')) - ], + // endpoints that are usable without a user session + $capabilities = [ + 'notify_push' => [ + 'endpoints' => [ + 'websocket' => $wsEndpoint, + 'anon_pre_auth' => $this->url('notify_push.AnonymousAuth.preAuth'), ], - ]; - } else { - return []; + ], + ]; + + if ($this->userSession->isLoggedIn()) { + $capabilities['notify_push']['type'] = ['files', 'activities', 'notifications']; + $capabilities['notify_push']['endpoints']['pre_auth'] = $this->url('notify_push.Auth.preAuth'); } + + return $capabilities; + } + + private function url(string $route): string { + return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute($route)); } } diff --git a/lib/Controller/AnonymousAuthController.php b/lib/Controller/AnonymousAuthController.php new file mode 100644 index 0000000..44f874b --- /dev/null +++ b/lib/Controller/AnonymousAuthController.php @@ -0,0 +1,47 @@ +sessionManager->validateToken($token); + if ($sessionId === null) { + return new DataDisplayResponse('Invalid or expired session token', Http::STATUS_UNAUTHORIZED); + } + + return new DataDisplayResponse($this->sessionManager->preAuthenticate($sessionId)); + } +} diff --git a/lib/IAnonymousSessionManager.php b/lib/IAnonymousSessionManager.php new file mode 100644 index 0000000..bf81373 --- /dev/null +++ b/lib/IAnonymousSessionManager.php @@ -0,0 +1,73 @@ +binaryFinder = $setupWizard; + $this->appConfig = $appConfig; + $this->random = $random; } public function getName() { @@ -29,5 +37,24 @@ public function getName() { public function run(IOutput $output) { $path = $this->binaryFinder->getBinaryPath(); @chmod($path, 0755); + + $this->setupAnonymousSessionKey(); + } + + /** + * Generate the key used to sign anonymous session tokens ahead of time, so concurrent + * requests can't race each other generating it. + */ + private function setupAnonymousSessionKey(): void { + if ($this->appConfig->getValueString(Application::APP_ID, AnonymousSessionManager::SIGNING_KEY_CONFIG_KEY) !== '') { + return; + } + + $this->appConfig->setValueString( + Application::APP_ID, + AnonymousSessionManager::SIGNING_KEY_CONFIG_KEY, + $this->random->generate(AnonymousSessionManager::SIGNING_KEY_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC), + sensitive: true, + ); } } diff --git a/src/connection.rs b/src/connection.rs index 044d09a..fdb927e 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -25,6 +25,8 @@ use warp::filters::ws::{Message, WebSocket}; const USER_CONNECTION_LIMIT: usize = 64; const PING_INTERVAL: Duration = Duration::from_secs(30); +/// How long a pre-auth token can be used to authenticate a socket after it has been requested +pub const PRE_AUTH_VALIDITY: Duration = Duration::from_secs(15); #[derive(Default)] pub struct ActiveConnections(DashMap, PassthruHasher>); @@ -271,8 +273,8 @@ async fn socket_auth( .map_err(|_| AuthenticationError::InvalidMessage)? .trim(); - // cleanup all pre_auth tokens older than 15s - let cutoff = Instant::now() - Duration::from_secs(15); + // cleanup all expired pre_auth tokens + let cutoff = Instant::now() - PRE_AUTH_VALIDITY; app.pre_auth.retain(|_, (time, _)| *time > cutoff); if let Some((_, (_, user))) = app.pre_auth.remove(password) { diff --git a/src/event.rs b/src/event.rs index 2f2199f..6b73250 100644 --- a/src/event.rs +++ b/src/event.rs @@ -11,9 +11,39 @@ use redis::Msg; use serde::Deserialize; use serde_json::Value; use std::convert::TryFrom; +use std::fmt; use thiserror::Error; use tokio_stream::{Stream, StreamExt}; +/// The recipient of a message, either a logged in user or an anonymous session +/// +/// Serialized as a single field, either `{"user": "uid"}` or `{"session": "session id"}` +#[derive(Debug, Deserialize)] +pub enum Target { + #[serde(rename = "user")] + User(String), + #[serde(rename = "session")] + AnonymousSession(String), +} + +impl Target { + pub fn id(&self) -> UserId { + match self { + Target::User(user) => UserId::new(user), + Target::AnonymousSession(session) => UserId::anonymous(session), + } + } +} + +impl fmt::Display for Target { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Target::User(user) => write!(f, "user {user}"), + Target::AnonymousSession(session) => write!(f, "anonymous session {session}"), + } + } +} + #[derive(Debug, Deserialize)] pub struct StorageUpdate { pub storage: u32, @@ -44,7 +74,8 @@ pub struct Notification { #[derive(Debug, Deserialize)] pub struct PreAuth { - pub user: UserId, + #[serde(flatten)] + pub target: Target, pub token: String, } @@ -63,10 +94,11 @@ pub enum Query { #[derive(Debug, Deserialize)] pub struct Custom { - pub user: UserId, + #[serde(flatten)] + pub target: Target, pub message: String, #[serde(default)] - pub body: Box, // use `Box` to reduce size of `Event` enum from 72 to 48 bytes + pub body: Box, // use `Box` to keep the size of the `Event` enum down } #[derive(Debug, Deserialize, Display)] @@ -89,9 +121,9 @@ pub enum Event { Activity(Activity), #[display("notification notification for user {0.user}")] Notification(Notification), - #[display("pre_auth user {0.user}")] + #[display("pre_auth {0.target}")] PreAuth(PreAuth), - #[display("custom notification {0.message} for user {0.user}")] + #[display("custom notification {0.message} for {0.target}")] Custom(Custom), #[display("config update")] Config(Config), @@ -152,6 +184,28 @@ impl TryFrom for Event { } } +#[test] +fn test_decode_custom_target() { + let user: Custom = serde_json::from_str(r#"{"user":"foo","message":"msg"}"#).unwrap(); + assert_eq!(user.target.id(), UserId::new("foo")); + + let session: Custom = + serde_json::from_str(r#"{"session":"myapp:foo","message":"msg","body":null}"#).unwrap(); + assert_eq!(session.target.id(), UserId::anonymous("myapp:foo")); + assert_eq!(*session.body, Value::Null); + + let with_body: Custom = + serde_json::from_str(r#"{"session":"myapp:foo","message":"msg","body":{"a":1}}"#).unwrap(); + assert_eq!(*with_body.body, serde_json::json!({"a": 1})); + + let pre_auth: PreAuth = + serde_json::from_str(r#"{"session":"myapp:foo","token":"tok"}"#).unwrap(); + assert_eq!(pre_auth.target.id(), UserId::anonymous("myapp:foo")); + assert_eq!(pre_auth.token, "tok"); + + assert!(serde_json::from_str::(r#"{"message":"msg"}"#).is_err()); +} + pub async fn subscribe( client: &Redis, ) -> Result<( diff --git a/src/lib.rs b/src/lib.rs index ac95698..df46149 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ use crate::config::{Bind, Config, TlsConfig}; -use crate::connection::{handle_user_socket, ActiveConnections, ConnectionOptions}; +use crate::connection::{ + handle_user_socket, ActiveConnections, ConnectionOptions, PRE_AUTH_VALIDITY, +}; pub use crate::error::Error; use crate::error::{SelfTestError, SocketError}; use crate::event::{ @@ -53,6 +55,9 @@ pub mod user; pub type Result = std::result::Result; +/// Number of outstanding pre-auth tokens after which expired tokens get cleaned up +const PRE_AUTH_CLEANUP_THRESHOLD: usize = 1024; + pub struct App { connections: ActiveConnections, nc_client: nc::Client, @@ -191,16 +196,22 @@ impl App { self.connections .send_to_user(&user, PushMessage::Notification); } - Event::PreAuth(PreAuth { user, token }) => { - self.pre_auth.insert(token, (Instant::now(), user)); + Event::PreAuth(PreAuth { target, token }) => { + // tokens are normally cleaned up when a socket authenticates, requested tokens + // that are never used would linger, so clean up once the map starts growing + if self.pre_auth.len() > PRE_AUTH_CLEANUP_THRESHOLD { + let cutoff = Instant::now() - PRE_AUTH_VALIDITY; + self.pre_auth.retain(|_, (time, _)| *time > cutoff); + } + self.pre_auth.insert(token, (Instant::now(), target.id())); } Event::Custom(Custom { - user, + target, message, body, }) => { self.connections - .send_to_user(&user, PushMessage::Custom(message, body)); + .send_to_user(&target.id(), PushMessage::Custom(message, body)); } Event::Config(event::Config::LogSpec(spec)) => { match self.log_handle.lock().await.parse_and_push_temp_spec(&spec) { diff --git a/src/user.rs b/src/user.rs index eaa683b..d0b5d40 100644 --- a/src/user.rs +++ b/src/user.rs @@ -21,6 +21,17 @@ static USER_NAMES: Lazy> = Lazy::new(DashMa // Use the same hash state for generating user hash for every instance static RANDOM_STATE: OnceBox = OnceBox::new(); +/// The kind of identity a connection is registered under +/// +/// The kind is hashed together with the id itself, ensuring that an anonymous session +/// can never end up with the same identity as a user, no matter what characters +/// the user backend allows in user ids. +#[derive(Clone, Copy, Eq, PartialEq)] +enum IdKind { + User = 0, + AnonymousSession = 1, +} + #[derive(Clone, Eq, PartialEq, Hash)] pub struct UserId { hash: u64, @@ -28,15 +39,26 @@ pub struct UserId { impl UserId { pub fn new(user_id: &str) -> Self { + UserId::with_kind(IdKind::User, user_id) + } + + /// Identity of an anonymous session, lives in a separate namespace from user ids + pub fn anonymous(session_id: &str) -> Self { + UserId::with_kind(IdKind::AnonymousSession, session_id) + } + + fn with_kind(kind: IdKind, id: &str) -> Self { let state = RANDOM_STATE.get_or_init(|| Box::new(RandomState::new())); let mut hash = state.build_hasher(); - hash.write(user_id.as_bytes()); + hash.write_u8(kind as u8); + hash.write(id.as_bytes()); let hash = hash.finish(); if log::max_level() >= LevelFilter::Info { - USER_NAMES - .entry(hash) - .or_insert_with(|| user_id.to_string()); + USER_NAMES.entry(hash).or_insert_with(|| match kind { + IdKind::User => id.to_string(), + IdKind::AnonymousSession => format!("anonymous session {id}"), + }); } UserId { hash } @@ -130,3 +152,11 @@ impl fmt::Debug for UserId { } } } + +#[test] +fn test_anonymous_session_doesnt_collide_with_user() { + assert_ne!(UserId::new("admin"), UserId::anonymous("admin")); + assert_eq!(UserId::new("admin"), UserId::new("admin")); + assert_eq!(UserId::anonymous("admin"), UserId::anonymous("admin")); + assert_ne!(UserId::anonymous("foo"), UserId::anonymous("bar")); +} diff --git a/tests/integration.rs b/tests/integration.rs index bfd580a..6ec5a48 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -471,6 +471,100 @@ async fn test_pre_auth() { assert_next_message(&mut client, "notify_activity").await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_anonymous_session() { + let services = Services::new().await; + + let server_handle = services.spawn_server().await; + + sleep(Duration::from_millis(500)).await; + + let mut redis = services.redis_client().await; + redis + .publish::<_, _, ()>( + "notify_pre_auth", + r#"{"session":"myapp:session1", "token": "token"}"#, + ) + .await + .unwrap(); + + sleep(Duration::from_millis(100)).await; + + let mut client = server_handle.connect_auth("", "token").await; + + redis + .publish::<_, _, ()>( + "notify_custom", + r#"{"session":"myapp:session1", "message":"my_custom_message", "body": {"foo": "bar"}}"#, + ) + .await + .unwrap(); + + assert_next_message(&mut client, r#"my_custom_message {"foo":"bar"}"#).await; + + // messages for a different session don't get delivered + redis + .publish::<_, _, ()>( + "notify_custom", + r#"{"session":"myapp:session2", "message":"my_custom_message"}"#, + ) + .await + .unwrap(); + + assert_no_message(&mut client).await; +} + +/// An anonymous session must never share the identity of a user with the same id +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_anonymous_session_isolated_from_user() { + let services = Services::new().await; + services.add_user("foo", "bar"); + + let server_handle = services.spawn_server().await; + + sleep(Duration::from_millis(500)).await; + + let mut redis = services.redis_client().await; + redis + .publish::<_, _, ()>("notify_pre_auth", r#"{"session":"foo", "token": "token"}"#) + .await + .unwrap(); + + sleep(Duration::from_millis(100)).await; + + let mut anonymous_client = server_handle.connect_auth("", "token").await; + let mut user_client = server_handle.connect_auth("foo", "bar").await; + + // events for the user don't leak into the session with the same id + redis + .publish::<_, _, ()>("notify_activity", r#"{"user":"foo"}"#) + .await + .unwrap(); + redis + .publish::<_, _, ()>( + "notify_custom", + r#"{"user":"foo", "message":"for_the_user"}"#, + ) + .await + .unwrap(); + + assert_next_message(&mut user_client, "notify_activity").await; + assert_next_message(&mut user_client, "for_the_user").await; + assert_no_message(&mut anonymous_client).await; + + // and the other way around + redis + .publish::<_, _, ()>( + "notify_custom", + r#"{"session":"foo", "message":"for_the_session"}"#, + ) + .await + .unwrap(); + + assert_next_message(&mut anonymous_client, "for_the_session").await; + assert_no_message(&mut user_client).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_notify_notification() { let services = Services::new().await; diff --git a/tests/lib/AnonymousSessionManagerTest.php b/tests/lib/AnonymousSessionManagerTest.php new file mode 100644 index 0000000..ed0494c --- /dev/null +++ b/tests/lib/AnonymousSessionManagerTest.php @@ -0,0 +1,196 @@ +createMock(IQueue::class); + $queue->method('push')->willReturnCallback(function ($channel, $event) { + $this->events[$channel][] = $event; + }); + + $random = $this->createMock(ISecureRandom::class); + $random->method('generate')->willReturnCallback(function (int $length) { + $this->randomCounter++; + return str_pad('r' . $this->randomCounter, $length, 'x'); + }); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback(fn () => $this->signingKey); + $appConfig->method('setValueString')->willReturnCallback(function ($app, $key, $value) { + $this->signingKey = $value; + return true; + }); + + $timeFactory = $this->createMock(ITimeFactory::class); + $timeFactory->method('getTime')->willReturnCallback(fn () => $this->time); + + return new AnonymousSessionManager($queue, $random, $appConfig, $timeFactory); + } + + public function testCreateAndValidate(): void { + $manager = $this->getManager(); + + $session = $manager->createSession('myapp'); + + $this->assertStringStartsWith('myapp:', $session->getId()); + $this->assertEquals($this->time + IAnonymousSessionManager::DEFAULT_TTL, $session->getExpiration()); + $this->assertEquals($session->getId(), $manager->validateToken($session->getToken())); + } + + public function testSessionIdsAreUnique(): void { + $manager = $this->getManager(); + + $this->assertNotEquals( + $manager->createSession('myapp')->getId(), + $manager->createSession('myapp')->getId(), + ); + } + + public function testRenewToken(): void { + $manager = $this->getManager(); + $session = $manager->createSession('myapp', 100); + + $this->time += 50; + $renewed = $manager->renewToken($session->getId(), 100); + + $this->assertEquals($session->getId(), $renewed->getId()); + $this->assertNotEquals($session->getToken(), $renewed->getToken()); + $this->assertEquals($this->time + 100, $renewed->getExpiration()); + + // the old token keeps working until it expires on its own + $this->time += 51; + $this->assertNull($manager->validateToken($session->getToken())); + $this->assertEquals($session->getId(), $manager->validateToken($renewed->getToken())); + } + + public function testRenewInvalidSessionId(): void { + $this->expectException(\InvalidArgumentException::class); + $this->getManager()->renewToken('not a session id'); + } + + public function testTokenStaysValidForReconnects(): void { + $manager = $this->getManager(); + $session = $manager->createSession('myapp', 100); + + $this->time += 50; + + $this->assertEquals($session->getId(), $manager->validateToken($session->getToken())); + $this->assertEquals($session->getId(), $manager->validateToken($session->getToken())); + } + + public function testExpiredToken(): void { + $manager = $this->getManager(); + $session = $manager->createSession('myapp', 100); + + $this->time += 101; + + $this->assertNull($manager->validateToken($session->getToken())); + } + + /** + * @return array + */ + public static function invalidTokenProvider(): array { + return [ + 'empty' => [''], + 'no signature' => ['eyJpZCI6ICJteWFwcDpmb28iLCAiZXhwIjogOTk5OTk5OTk5OX0'], + 'too many parts' => ['a.b.c'], + 'garbage payload' => ['!!!.!!!'], + ]; + } + + /** + * @dataProvider invalidTokenProvider + */ + public function testInvalidToken(string $token): void { + $this->assertNull($this->getManager()->validateToken($token)); + } + + public function testTamperedToken(): void { + $manager = $this->getManager(); + $session = $manager->createSession('myapp', 100); + + [$payload, $signature] = explode('.', $session->getToken()); + $forgedPayload = rtrim(strtr(base64_encode(json_encode([ + 'id' => 'otherapp:stolen', + 'exp' => $this->time + 100, + ])), '+/', '-_'), '='); + + $this->assertNull($manager->validateToken($forgedPayload . '.' . $signature)); + $this->assertNull($manager->validateToken($payload . '.' . strrev($signature))); + } + + public function testTokenFromDifferentInstance(): void { + $manager = $this->getManager(); + $session = $manager->createSession('myapp'); + + // simulate a different instance, and therefore a different signing key + $this->signingKey = ''; + $otherManager = $this->getManager(); + + $this->assertNull($otherManager->validateToken($session->getToken())); + } + + public function testInvalidAppId(): void { + $this->expectException(\InvalidArgumentException::class); + $this->getManager()->createSession('my app'); + } + + public function testInvalidTtl(): void { + $this->expectException(\InvalidArgumentException::class); + $this->getManager()->createSession('myapp', IAnonymousSessionManager::MAX_TTL + 1); + } + + public function testSend(): void { + $manager = $this->getManager(); + + $manager->send('myapp:session1', 'my_message', ['foo' => 'bar']); + $manager->send('myapp:session1', 'my_message'); + + $this->assertEquals([ + [ + 'session' => 'myapp:session1', + 'message' => 'my_message', + 'body' => ['foo' => 'bar'], + ], + [ + 'session' => 'myapp:session1', + 'message' => 'my_message', + 'body' => null, + ], + ], $this->events['notify_custom']); + } + + public function testPreAuthenticate(): void { + $manager = $this->getManager(); + + $token = $manager->preAuthenticate('myapp:session1'); + + $this->assertEquals([ + [ + 'session' => 'myapp:session1', + 'token' => $token, + ], + ], $this->events['notify_pre_auth']); + } +}