Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,42 @@ Notes:
- Transformers may return `MissingValue` / `MergeValue` objects; they are filtered like `when()` / `merge()` output. Keys excluded with `without()` stay excluded.
- Re-registering a class replaces its options; `ResourceTransformerRegistry::forget()` and `reset()` remove registrations.
- The registry is a container singleton (`app(ResourceTransformerRegistry::class)`); registrations happen at boot and are shared by every request in an Octane worker.

## Realtime channel authentication

Authenticated realtime channels are on only when `SOCKETCLUSTER_AUTH_ENABLED=true` **and** `SOCKETCLUSTER_AUTH_KEY` is set (a shared secret of at least 32 characters, also given to the socket server). Until then nothing changes: no socket tokens are minted, the token routes answer 404, broadcasts use the websocket publisher as before, and the console's socket test publishes to the channel it asks for.

The switch is separate from the key so a deployment can provision the key ahead of time and keep every existing socket client working (mobile apps, the console, integrations) until they all fetch socket tokens. Roll out in this order:
1. Ship clients that request a socket token and fall back to connecting without one when the token route answers 404.
2. Set `SOCKETCLUSTER_AUTH_ENABLED=true` on the API, queue and scheduler, and run the socket server with `SOCKETCLUSTER_AUTH_MODE=log`.
3. Check the socket server's deny log, then switch it to `enforce`.

| Variable | Default | Meaning |
|---|---|---|
| `SOCKETCLUSTER_AUTH_ENABLED` | `false` | Turns authenticated realtime channels on. Has no effect without `SOCKETCLUSTER_AUTH_KEY`. |
| `SOCKETCLUSTER_AUTH_KEY` | unset | Signs socket tokens (HS256) and, through derived keys, the API to socket server requests. |
| `SOCKETCLUSTER_PUBLISH_URL` | `http://{SOCKETCLUSTER_HOST}:8001` | The socket server's internal listener; broadcasts are sent as one signed `POST {url}/publish`. |
| `SOCKETCLUSTER_TOKEN_TTL` | `900` | Lifetime in seconds of user, API, driver, customer and checkout tokens. |
| `SOCKETCLUSTER_ORIGIN` | unset | `Origin` header the websocket publisher sends on its handshake. Set it to an origin the socket server allows (e.g. the console URL) when `SOCKETCLUSTER_OPTIONS` restricts `origins`; without it the handshake is refused as `Invalid origin: *`. Not used by the signed HTTP publish. |

Clients fetch a token before connecting: `POST int/v1/socket/token` (console session), `POST v1/socket/token` (API credential or Sanctum user token). The socket server asks `POST int/v1/socket/authorize`, signed with its own derived key, whether a token may subscribe to a channel.

A channel is authorized by the resolver registered for its prefix (the part before the first `.`); unknown prefixes are denied. Extensions register theirs from their service provider:

```php
use Fleetbase\Support\SocketCluster\SocketChannelRegistry;
use Fleetbase\Support\SocketCluster\SocketPrincipal;

$registry = app(SocketChannelRegistry::class);

// `order.{uuid|public_id}`: users and API credentials of the order's company; drivers only when $narrow agrees.
$registry->registerModel('order', Order::class, fn (SocketPrincipal $p, Order $order) => $p->kind === 'driver' && $p->owns((string) $order->driver_assigned_uuid));

// Anything else: fn (SocketPrincipal $p, string $id, string $channel): bool
$registry->register('fleet', fn (SocketPrincipal $p, string $id, string $channel) => /* ... */ false);

// Claim a Sanctum-authenticated user as a more specific principal on `POST v1/socket/token`.
$registry->registerPrincipalResolver(fn (Request $request, $user) => /* ?SocketPrincipal */ null);
```

`app(ChannelAuthorizer::class)->authorize($principal, $channel)` gives the same decision anywhere in PHP.
14 changes: 14 additions & 0 deletions config/broadcasting.connections.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,21 @@
'port' => env('SOCKETCLUSTER_PORT', 8000),
'path' => env('SOCKETCLUSTER_PATH', '/socketcluster/'),
'query' => [],
// The websocket publisher sends no Origin of its own, and a socket server whose
// `origins` are restricted (as scripts/docker-install.sh sets them) rejects a
// handshake without one. Set SOCKETCLUSTER_ORIGIN to an allowed origin, e.g. the
// console URL.
'headers' => array_filter(['Origin' => env('SOCKETCLUSTER_ORIGIN')]),
],

// Realtime channel authentication. It is off unless SOCKETCLUSTER_AUTH_ENABLED is true
// and SOCKETCLUSTER_AUTH_KEY is set: until then no socket tokens are minted and
// broadcasts use the websocket publisher, so existing socket clients keep working.
// Turn it on once every client fetches socket tokens.
'auth_enabled' => Utils::castBoolean(env('SOCKETCLUSTER_AUTH_ENABLED', false)),
'auth_key' => env('SOCKETCLUSTER_AUTH_KEY'),
'publish_url' => env('SOCKETCLUSTER_PUBLISH_URL', 'http://' . env('SOCKETCLUSTER_HOST', 'socket') . ':8001'),
'token_ttl' => (int) env('SOCKETCLUSTER_TOKEN_TTL', 900),
],

// for apple apn
Expand Down
17 changes: 17 additions & 0 deletions src/Contracts/SocketChannelResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Fleetbase\Contracts;

use Fleetbase\Support\SocketCluster\SocketPrincipal;

/**
* Decides whether a socket principal may subscribe to channels under one prefix.
*
* Registered against a prefix on the SocketChannelRegistry. For a channel such as
* `order.order_abc123` the resolver registered for `order` receives `order_abc123`
* as the id and the full channel name.
*/
interface SocketChannelResolver
{
public function authorize(SocketPrincipal $principal, string $id, string $channel): bool;
}
19 changes: 18 additions & 1 deletion src/Http/Controllers/Internal/v1/SettingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1040,14 +1040,31 @@ public function testSentryConfig(AdminRequest $request)
/**
* Test SocketCluster Configuration.
*
* With socket authentication on, publishes only to the signed-in user's own
* `test.{user uuid}` channel and ignores any channel in the request. With it off, publishes
* to the requested channel (default `test`) as before, so existing consoles keep working.
* The channel used is returned so the console can subscribe to it.
*
* @param Request $request the incoming HTTP request containing the authenticated user
*
* @return \Illuminate\Http\JsonResponse returns a JSON response with a success message and HTTP status 200
*/
public function testSocketcluster(AdminRequest $request)
{
$userUuid = session('user');
$scoped = \Fleetbase\Support\SocketCluster\SocketToken::enabled();

if ($scoped && (!is_string($userUuid) || $userUuid === '')) {
return response()->json([
'status' => 'error',
'message' => 'No signed-in user to publish the test message for.',
'channel' => null,
'response' => null,
]);
}

// Get the channel to publish to
$channel = $request->input('channel', 'test');
$channel = $scoped ? 'test.' . $userUuid : (string) $request->input('channel', 'test');
$message = 'Socket broadcasted message successfully.';
$status = 'success';
$sent = false;
Expand Down
113 changes: 113 additions & 0 deletions src/Http/Controllers/SocketAuthController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

namespace Fleetbase\Http\Controllers;

use Fleetbase\Models\User;
use Fleetbase\Support\Auth;
use Fleetbase\Support\SocketCluster\ChannelAuthorizer;
use Fleetbase\Support\SocketCluster\ChannelDecision;
use Fleetbase\Support\SocketCluster\SocketChannelRegistry;
use Fleetbase\Support\SocketCluster\SocketPrincipal;
use Fleetbase\Support\SocketCluster\SocketToken;
use Fleetbase\Support\Utils;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;

/**
* Socket token minting for console, API and platform clients, and the channel authorize
* endpoint the socket server calls. Every action answers 404 while SOCKETCLUSTER_AUTH_KEY
* is unset, which clients read as "connect anonymously".
*/
class SocketAuthController extends Controller
{
/**
* POST int/v1/socket/token: a user token for the signed-in console session.
*/
public function token(Request $request)
{
if (!SocketToken::enabled()) {
return static::disabled();
}

$user = $request->user();

if (!$user instanceof User) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}

$principal = SocketPrincipal::forUser($user);

// The console's sandbox toggle reads and writes the sandbox database.
if (Utils::isTrue($request->header('Access-Console-Sandbox'))) {
$principal = $principal->with(['env' => 'test']);
}

return response()->json(SocketToken::issue($principal));
}

/**
* POST v1/socket/token: an api token for an API credential; for a Sanctum user token,
* the principal a registered resolver claims (a driver, for FleetOps) or else a user token.
*/
public function apiToken(Request $request)
{
if (!SocketToken::enabled()) {
return static::disabled();
}

$bearer = $request->bearerToken();
$personalAccessToken = $bearer ? PersonalAccessToken::findToken($bearer) : null;

if ($personalAccessToken !== null && $personalAccessToken->tokenable instanceof User) {
$user = $personalAccessToken->tokenable;
$principal = app(SocketChannelRegistry::class)->resolvePrincipal($request, $user) ?? SocketPrincipal::forUser($user, $user->company_uuid);

return response()->json(SocketToken::issue($principal));
}

$credential = Auth::getApiKey();

if ($credential === null) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}

return response()->json(SocketToken::issue(SocketPrincipal::forApiCredential($credential)));
}

/**
* A system token for a platform API caller.
*/
public function systemToken(Request $request)
{
if (!SocketToken::enabled()) {
return static::disabled();
}

return response()->json(SocketToken::issue(SocketPrincipal::system()));
}

/**
* POST int/v1/socket/authorize: the socket server asks whether a token may subscribe to a channel.
*
* The token is verified here again; nothing the socket server derived from it is trusted.
*/
public function authorizeChannel(Request $request)
{
$token = $request->input('token');
$channel = $request->input('channel');
$hasToken = is_string($token) && $token !== '';
$principal = $hasToken ? SocketToken::verify($token) : null;
$decision = app(ChannelAuthorizer::class)->authorize($principal, is_string($channel) ? $channel : '');

if ($hasToken && $principal === null && !$decision->allow) {
$decision = ChannelDecision::denied('invalid_token');
}

return response()->json($decision->toArray());
}

protected static function disabled()
{
return response()->json(['error' => 'Not Found'], 404);
}
}
6 changes: 6 additions & 0 deletions src/Http/Middleware/EnsureFleetbaseConfigured.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ protected function shouldCheck(Request $request): bool
return false;
}

// The socket server asks this endpoint whether an anonymous install page may follow
// the install channel, which is exactly the case before setup has finished.
if ($request->is('int/v1/socket/authorize') || $request->is('*/int/v1/socket/authorize')) {
return false;
}

return $request->is('int/*')
|| $request->is('*/int/*')
|| $request->is('v1/*')
Expand Down
36 changes: 36 additions & 0 deletions src/Http/Middleware/VerifySocketSignature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Fleetbase\Http\Middleware;

use Fleetbase\Support\SocketCluster\SocketSignature;
use Fleetbase\Support\SocketCluster\SocketToken;
use Illuminate\Http\Request;

/**
* Admits only requests the socket server signed with the derived authorize key.
*
* Used instead of session or token auth on the channel authorize endpoint, which only the
* socket server calls.
*/
class VerifySocketSignature
{
public function handle(Request $request, \Closure $next)
{
if (!SocketToken::enabled()) {
return response()->json(['error' => 'Not Found'], 404);
}

$valid = SocketSignature::verify(
SocketSignature::AUTHORIZE,
$request->header(SocketSignature::HEADER_TIMESTAMP),
$request->header(SocketSignature::HEADER_SIGNATURE),
$request->getContent()
);

if (!$valid) {
return response()->json(['error' => 'invalid_signature'], 401);
}

return $next($request);
}
}
25 changes: 25 additions & 0 deletions src/Providers/SocketClusterServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,38 @@

namespace Fleetbase\Providers;

use Fleetbase\Support\SocketCluster\ChannelAuthorizer;
use Fleetbase\Support\SocketCluster\CoreChannelResolvers;
use Fleetbase\Support\SocketCluster\SocketChannelRegistry;
use Fleetbase\Support\SocketCluster\SocketClusterBroadcaster;
use Fleetbase\Support\SocketCluster\SocketClusterService;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;

class SocketClusterServiceProvider extends ServiceProvider
{
/**
* Register the realtime channel registry and authorizer.
*
* Singletons: extensions add their channel resolvers to the registry from their own
* service providers, and core's resolvers are registered when it is first built.
*
* @return void
*/
public function register()
{
$this->app->singleton(SocketChannelRegistry::class, function () {
$registry = new SocketChannelRegistry();
CoreChannelResolvers::register($registry);

return $registry;
});

$this->app->singleton(ChannelAuthorizer::class, function ($app) {
return new ChannelAuthorizer($app->make(SocketChannelRegistry::class));
});
}

/**
* Register new BroadcastManager in boot.
*
Expand Down
Loading
Loading