Skip to content

Commit 99a6008

Browse files
committed
Add request authentication to Server with bearer token support
1 parent 3607ef4 commit 99a6008

7 files changed

Lines changed: 202 additions & 16 deletions

File tree

phpstan.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ parameters:
1313
- "#^Constant DEPLOYER_VERSION not found\\.$#"
1414
- "#^Constant DEPLOYER_BIN not found\\.$#"
1515
- "#^Constant MASTER_ENDPOINT not found\\.$#"
16+
- "#^Constant MASTER_TOKEN not found\\.$#"
1617
- "#CpanelPhp#"
1718
- "#AMQPMessage#"

src/Command/WorkerCommand.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
4444
}
4545

4646
define('MASTER_ENDPOINT', 'http://localhost:' . $input->getOption('port'));
47+
define('MASTER_TOKEN', getenv('DEPLOYER_MASTER_TOKEN') ?: '');
4748

4849
$task = $this->deployer->tasks->get($input->getOption('task'));
4950
$host = $this->deployer->hosts->get($input->getOption('host'));

src/Configuration.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ public function load(): void
183183
$values = Httpie::get(MASTER_ENDPOINT . '/load')
184184
->setopt(CURLOPT_CONNECTTIMEOUT, 0)
185185
->setopt(CURLOPT_TIMEOUT, 0)
186+
->header('Authorization', 'Bearer ' . MASTER_TOKEN)
186187
->jsonBody([
187188
'host' => $this->get('alias'),
188189
])
@@ -199,6 +200,7 @@ public function save(): void
199200
Httpie::get(MASTER_ENDPOINT . '/save')
200201
->setopt(CURLOPT_CONNECTTIMEOUT, 0)
201202
->setopt(CURLOPT_TIMEOUT, 0)
203+
->header('Authorization', 'Bearer ' . MASTER_TOKEN)
202204
->jsonBody([
203205
'host' => $this->get('alias'),
204206
'config' => $this->persist(),

src/Deployer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ public static function masterCall(Host $host, string $func, mixed ...$arguments)
317317
return Httpie::get(MASTER_ENDPOINT . '/proxy')
318318
->setopt(CURLOPT_CONNECTTIMEOUT, 0) // no timeout
319319
->setopt(CURLOPT_TIMEOUT, 0) // no timeout
320+
->header('Authorization', 'Bearer ' . MASTER_TOKEN)
320321
->jsonBody([
321322
'host' => $host->getAlias(),
322323
'func' => $func,

src/Executor/Master.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,15 @@ private function runTask(Task $task, array $hosts): int
165165
}
166166

167167
$server = new Server('127.0.0.1', 0, $this->output);
168+
$authToken = bin2hex(random_bytes(16));
169+
$server->setAuthToken($authToken);
168170

169171
/** @var Process[] $processes */
170172
$processes = [];
171173

172-
$server->afterRun(function (int $port) use (&$processes, $hosts, $task) {
174+
$server->afterRun(function (int $port) use (&$processes, $hosts, $task, $authToken) {
173175
foreach ($hosts as $host) {
174-
$processes[] = $this->createProcess($host, $task, $port);
176+
$processes[] = $this->createProcess($host, $task, $port, $authToken);
175177
}
176178

177179
foreach ($processes as $process) {
@@ -242,7 +244,7 @@ private function runTask(Task $task, array $hosts): int
242244
return $this->cumulativeExitCode($processes);
243245
}
244246

245-
protected function createProcess(Host $host, Task $task, int $port): Process
247+
protected function createProcess(Host $host, Task $task, int $port, string $authToken): Process
246248
{
247249
$command = [
248250
$this->phpBin, DEPLOYER_BIN,
@@ -257,7 +259,9 @@ protected function createProcess(Host $host, Task $task, int $port): Process
257259
if ($this->output->isDebug()) {
258260
$this->output->writeln("[$host] " . join(' ', $command));
259261
}
260-
return new Process($command);
262+
$process = new Process($command);
263+
$process->setEnv(['DEPLOYER_MASTER_TOKEN' => $authToken]);
264+
return $process;
261265
}
262266

263267
/**

src/Executor/Server.php

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,16 @@ class Server
3838
private Closure $tickerCallback;
3939
private Closure $routerCallback;
4040

41+
private ?string $authToken = null;
42+
4143
/**
4244
* Timeout in seconds for idle client connections.
4345
*/
4446
private float $clientTimeout = 30.0;
4547

4648
private const REASON_PHRASES = [
4749
200 => 'OK',
50+
403 => 'Forbidden',
4851
404 => 'Not Found',
4952
500 => 'Internal Server Error',
5053
];
@@ -167,7 +170,16 @@ private function handleClientRequests(): void
167170

168171
// Process the complete request.
169172
try {
170-
[$path, $payload] = self::parseRequest($buffer);
173+
[$path, $payload, $headers] = self::parseRequest($buffer);
174+
175+
if ($this->authToken !== null) {
176+
$provided = $headers['authorization'] ?? '';
177+
if ($provided !== "Bearer {$this->authToken}") {
178+
$this->sendResponse($socket, new Response(403, ['error' => 'Forbidden']));
179+
continue;
180+
}
181+
}
182+
171183
$response = ($this->routerCallback)($path, $payload);
172184
$this->sendResponse($socket, $response);
173185
} catch (\Throwable $e) {
@@ -208,9 +220,9 @@ public static function isCompleteRequest(string $buffer): bool
208220
}
209221

210222
/**
211-
* Parse a complete HTTP request into path and JSON payload.
223+
* Parse a complete HTTP request into path, payload, and headers.
212224
*
213-
* @return array{0: string, 1: mixed}
225+
* @return array{0: string, 1: mixed, 2: array<string, string>}
214226
*/
215227
public static function parseRequest(string $request): array
216228
{
@@ -249,7 +261,7 @@ public static function parseRequest(string $request): array
249261
}
250262

251263
$payload = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
252-
return [$path, $payload];
264+
return [$path, $payload, $headers];
253265
}
254266

255267
/**
@@ -310,6 +322,11 @@ public function router(Closure $callback): void
310322
$this->routerCallback = $callback;
311323
}
312324

325+
public function setAuthToken(string $token): void
326+
{
327+
$this->authToken = $token;
328+
}
329+
313330
public function stop(): void
314331
{
315332
$this->stop = true;

tests/src/Executor/ServerTest.php

Lines changed: 168 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,11 @@ public function testParseRequestValid(): void
7171
$body = '{"host":"web","config":{}}';
7272
$request = "POST /save HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body;
7373

74-
[$path, $payload] = Server::parseRequest($request);
74+
[$path, $payload, $headers] = Server::parseRequest($request);
7575

7676
self::assertSame('/save', $path);
7777
self::assertSame(['host' => 'web', 'config' => []], $payload);
78+
self::assertSame('application/json', $headers['content-type']);
7879
}
7980

8081
#[Group('unit')]
@@ -83,7 +84,7 @@ public function testParseRequestTrimsBodyToContentLength(): void
8384
$body = '{"a":1}';
8485
$request = "POST /save HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body . "garbage";
8586

86-
[$path, $payload] = Server::parseRequest($request);
87+
[$path, $payload, $headers] = Server::parseRequest($request);
8788

8889
self::assertSame('/save', $path);
8990
self::assertSame(['a' => 1], $payload);
@@ -95,7 +96,7 @@ public function testParseRequestCaseInsensitiveHeaders(): void
9596
$body = '{"ok":true}';
9697
$request = "POST /load HTTP/1.1\r\ncontent-type: application/json\r\ncontent-length: " . strlen($body) . "\r\n\r\n" . $body;
9798

98-
[$path, $payload] = Server::parseRequest($request);
99+
[$path, $payload, $headers] = Server::parseRequest($request);
99100

100101
self::assertSame('/load', $path);
101102
self::assertSame(['ok' => true], $payload);
@@ -119,6 +120,18 @@ public function testParseRequestThrowsOnMissingHeaderTerminator(): void
119120
Server::parseRequest("POST /save HTTP/1.1\r\nContent-Type: application/json");
120121
}
121122

123+
#[Group('unit')]
124+
public function testParseRequestReturnsAuthorizationHeader(): void
125+
{
126+
$body = '{"a":1}';
127+
$request = "POST /load HTTP/1.1\r\nContent-Type: application/json\r\nAuthorization: Bearer secret123\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body;
128+
129+
[$path, $payload, $headers] = Server::parseRequest($request);
130+
131+
self::assertSame('/load', $path);
132+
self::assertSame('Bearer secret123', $headers['authorization']);
133+
}
134+
122135
#[Group('unit')]
123136
public function testParseRequestThrowsOnInvalidContentType(): void
124137
{
@@ -165,14 +178,16 @@ public function testWriteAllWritesLargeData(): void
165178

166179
// ─── Integration: real server lifecycle ──────────────────────
167180

168-
private static function buildHttpRequest(string $method, string $path, array $payload): string
181+
private static function buildHttpRequest(string $method, string $path, array $payload, ?string $token = null): string
169182
{
170183
$body = json_encode($payload);
171-
return "$method $path HTTP/1.1\r\n"
184+
$headers = "$method $path HTTP/1.1\r\n"
172185
. "Content-Type: application/json\r\n"
173-
. "Content-Length: " . strlen($body) . "\r\n"
174-
. "\r\n"
175-
. $body;
186+
. "Content-Length: " . strlen($body) . "\r\n";
187+
if ($token !== null) {
188+
$headers .= "Authorization: Bearer $token\r\n";
189+
}
190+
return $headers . "\r\n" . $body;
176191
}
177192

178193
#[Group('integration')]
@@ -324,4 +339,149 @@ public function testServerHandlesMultipleConnections(): void
324339

325340
self::assertSame(2, $requestCount);
326341
}
342+
343+
#[Group('integration')]
344+
public function testServerRejectsRequestWithoutToken(): void
345+
{
346+
$output = new BufferedOutput();
347+
$server = new Server('127.0.0.1', 0, $output);
348+
$server->setAuthToken('secret-token');
349+
350+
$routerCalled = false;
351+
$server->router(function (string $path, array $payload) use (&$routerCalled) {
352+
$routerCalled = true;
353+
return new Response(200, ['ok' => true]);
354+
});
355+
356+
$clientSocket = null;
357+
$responseData = '';
358+
359+
$server->afterRun(function (int $port) use (&$clientSocket) {
360+
$clientSocket = stream_socket_client("tcp://127.0.0.1:$port", $errno, $errstr, 5);
361+
stream_set_blocking($clientSocket, false);
362+
// Send request without Authorization header.
363+
fwrite($clientSocket, self::buildHttpRequest('POST', '/load', ['host' => 'web']));
364+
});
365+
366+
$tickCount = 0;
367+
$server->ticker(function () use ($server, &$tickCount, &$clientSocket, &$responseData) {
368+
$tickCount++;
369+
if ($clientSocket) {
370+
$chunk = @fread($clientSocket, 65536);
371+
if ($chunk !== false && $chunk !== '') {
372+
$responseData .= $chunk;
373+
}
374+
if ($responseData !== '' && feof($clientSocket)) {
375+
fclose($clientSocket);
376+
$clientSocket = null;
377+
$server->stop();
378+
return;
379+
}
380+
}
381+
if ($tickCount >= 30) {
382+
$server->stop();
383+
}
384+
});
385+
386+
$server->run();
387+
388+
self::assertFalse($routerCalled, 'Router should not be called for unauthorized request');
389+
self::assertStringContainsString('403 Forbidden', $responseData);
390+
}
391+
392+
#[Group('integration')]
393+
public function testServerRejectsRequestWithWrongToken(): void
394+
{
395+
$output = new BufferedOutput();
396+
$server = new Server('127.0.0.1', 0, $output);
397+
$server->setAuthToken('correct-token');
398+
399+
$routerCalled = false;
400+
$server->router(function (string $path, array $payload) use (&$routerCalled) {
401+
$routerCalled = true;
402+
return new Response(200, ['ok' => true]);
403+
});
404+
405+
$clientSocket = null;
406+
$responseData = '';
407+
408+
$server->afterRun(function (int $port) use (&$clientSocket) {
409+
$clientSocket = stream_socket_client("tcp://127.0.0.1:$port", $errno, $errstr, 5);
410+
stream_set_blocking($clientSocket, false);
411+
fwrite($clientSocket, self::buildHttpRequest('POST', '/load', ['host' => 'web'], 'wrong-token'));
412+
});
413+
414+
$tickCount = 0;
415+
$server->ticker(function () use ($server, &$tickCount, &$clientSocket, &$responseData) {
416+
$tickCount++;
417+
if ($clientSocket) {
418+
$chunk = @fread($clientSocket, 65536);
419+
if ($chunk !== false && $chunk !== '') {
420+
$responseData .= $chunk;
421+
}
422+
if ($responseData !== '' && feof($clientSocket)) {
423+
fclose($clientSocket);
424+
$clientSocket = null;
425+
$server->stop();
426+
return;
427+
}
428+
}
429+
if ($tickCount >= 30) {
430+
$server->stop();
431+
}
432+
});
433+
434+
$server->run();
435+
436+
self::assertFalse($routerCalled, 'Router should not be called for wrong token');
437+
self::assertStringContainsString('403 Forbidden', $responseData);
438+
}
439+
440+
#[Group('integration')]
441+
public function testServerAcceptsRequestWithCorrectToken(): void
442+
{
443+
$output = new BufferedOutput();
444+
$server = new Server('127.0.0.1', 0, $output);
445+
$server->setAuthToken('correct-token');
446+
447+
$routerCalled = false;
448+
$server->router(function (string $path, array $payload) use (&$routerCalled) {
449+
$routerCalled = true;
450+
return new Response(200, ['ok' => true]);
451+
});
452+
453+
$clientSocket = null;
454+
$responseData = '';
455+
456+
$server->afterRun(function (int $port) use (&$clientSocket) {
457+
$clientSocket = stream_socket_client("tcp://127.0.0.1:$port", $errno, $errstr, 5);
458+
stream_set_blocking($clientSocket, false);
459+
fwrite($clientSocket, self::buildHttpRequest('POST', '/load', ['host' => 'web'], 'correct-token'));
460+
});
461+
462+
$tickCount = 0;
463+
$server->ticker(function () use ($server, &$tickCount, &$clientSocket, &$responseData) {
464+
$tickCount++;
465+
if ($clientSocket) {
466+
$chunk = @fread($clientSocket, 65536);
467+
if ($chunk !== false && $chunk !== '') {
468+
$responseData .= $chunk;
469+
}
470+
if ($responseData !== '' && feof($clientSocket)) {
471+
fclose($clientSocket);
472+
$clientSocket = null;
473+
$server->stop();
474+
return;
475+
}
476+
}
477+
if ($tickCount >= 30) {
478+
$server->stop();
479+
}
480+
});
481+
482+
$server->run();
483+
484+
self::assertTrue($routerCalled, 'Router should be called for authorized request');
485+
self::assertStringContainsString('200 OK', $responseData);
486+
}
327487
}

0 commit comments

Comments
 (0)