diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php --- a/src/app/AuthAttempt.php +++ b/src/app/AuthAttempt.php @@ -107,7 +107,9 @@ */ public function notify(): bool { - return CompanionApp::notifyUser($this->user_id, ['token' => $this->id]); + // TODO: Standardize the message type/content + return \App\Backends\Events::pushMessage($this->user, ['auth' => $this->id]); + // return CompanionApp::notifyUser($this->user_id, ['token' => $this->id]); } /** @@ -171,6 +173,16 @@ return $authAttempt; } + /** + * The authenticating user. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo(User::class); + } + /** * Trigger a notification if necessary and wait for confirmation. * diff --git a/src/app/Backends/Events.php b/src/app/Backends/Events.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/Events.php @@ -0,0 +1,81 @@ +companionApps() + ->where('mfa_enabled', true) + ->each(function ($app) use ($message, $type, &$count) { + if ($app->isPaired()) { + $count++; + // FIXME: Should we maybe use oauth_client_id or notification_token? + self::pushMessageToClient($app->device_id, $message, $type); + } + }); + + return $count > 0; + } + + /** + * Push a notification to specified client application + * + * @param string $clientId Client identifier + * @param string|array $message Message/data to push + * @param string $type Message type + * @param bool $prepend Prepend the message to the list (not append) + */ + public static function pushMessageToClient(string $clientId, $message, $type = 'message', $prepend = false) + { + $data = json_encode([ + 'type' => $type, + 'data' => $message, + ]); + + $cache_key = 'push:' . $clientId; + + if ($prepend) { + Redis::lPush($cache_key, $data); + } else { + Redis::rPush($cache_key, $data); + } + + // TODO: How to make these entries to expire? It looks that we can set + // TTL on the whole list, but not it's elements. I.e. we might need + // to store expires_on value in the $data and skip them in pullMessage(). + Redis::expire($cache_key, 10 * 60); + } +} diff --git a/src/app/Console/Development/PushEventCommand.php b/src/app/Console/Development/PushEventCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Development/PushEventCommand.php @@ -0,0 +1,43 @@ +getUser($this->argument('user')); + + if (!$user) { + $this->error("User not found."); + return 1; + } + + $message = $this->argument('message'); + $type = $this->option('type') ?: 'message'; + + Events::pushMessage($user, $message, $type); + } +} diff --git a/src/app/Http/Controllers/SSEController.php b/src/app/Http/Controllers/SSEController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/SSEController.php @@ -0,0 +1,104 @@ +debugPrefix = "PUSH [client={$clientId}, conn={$cid}]:"; + + \Log::debug("{$this->debugPrefix} Connection started"); + + $response = new StreamedResponse(); + + $response->headers->set('Content-Type', 'text/event-stream'); + $response->headers->set('X-Accel-Buffering', 'no'); + $response->headers->set('Cache-Control', 'no-cache,private'); + + $response->setCallback(function() use ($clientId) { + // Note: This first output makes the headers to be sent too + $this->sendEvent('start', ['type' => 'start']); + + while (true) { + // FIXME: connection_aborted() detects connection drop only after server + // sent something to the client, i.e. not immediately. Is there a better solution? + // We hope that at least the client can detect connection drops immediately + // and reconnect, otherwise potential to drop some notifications is quite big + if (connection_aborted()) { + \Log::debug("{$this->debugPrefix} Connection aborted"); + return; + } + + // Pull next message from the list (wait for it) + if ($element = Events::pullMessage($clientId, self::KEEPALIVE)) { + $this->sendEvent($element->type, $element->data); + } else { + $this->sendEvent('ping', ['type' => 'ping']); + } + } + }); + + return $response; + } + + /** + * Outputs a Server Side Event + */ + protected function sendEvent($event, $content): void + { + if (is_array($content)) { + $content = json_encode($content); + } + + echo 'event: ' . $event . PHP_EOL + . 'data: ' . $content . PHP_EOL . PHP_EOL; + + // If not under Octane we need to force output flush + if (empty($_SERVER['LARAVEL_OCTANE'])) { + ob_flush(); + flush(); + } + + \Log::debug("{$this->debugPrefix} Sent $event"); + } +} diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -204,6 +204,10 @@ function () { Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']); Route::post('meet', [API\V4\MeetController::class, 'webhook']); + + // FIXME: This end-point should probably be authenticated with a passport client token + // and moved to /api/v4/ instead of /api/webhooks/. Then maybe {client} part is not needed? + Route::get('sse/{client}', [\App\Http\Controllers\SSEController::class, 'notify']); } );