100 lines
4.4 KiB
PHP
Executable File
100 lines
4.4 KiB
PHP
Executable File
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace app\adminapi\service;
|
|
|
|
use RuntimeException;
|
|
|
|
/** Private single-server handoff tickets. Neither raw source tokens nor passwords are persisted. */
|
|
final class IdentityHospitalSsoTicketStore
|
|
{
|
|
private \Closure $clock;
|
|
|
|
public function __construct(private string $directory, ?callable $clock = null)
|
|
{
|
|
$this->clock = $clock === null ? static fn (): int => time() : \Closure::fromCallable($clock);
|
|
}
|
|
|
|
public function issue(int $sessionId, string $tokenHash, string $challenge): string
|
|
{
|
|
return $this->locked(function () use ($sessionId, $tokenHash, $challenge): string {
|
|
foreach (array_slice(glob($this->directory . '/*.json') ?: [], 0, 200) as $path) {
|
|
$old = $this->read($path);
|
|
if ($old !== null && (int) ($old['expires'] ?? 0) <= ($this->clock)()) { unlink($path); }
|
|
}
|
|
$ticket = bin2hex(random_bytes(32));
|
|
$record = ['session_id' => $sessionId, 'token_hash' => $tokenHash, 'challenge' => $challenge,
|
|
'expires' => ($this->clock)() + 30];
|
|
$path = $this->path($ticket);
|
|
$file = fopen($path, 'x+b');
|
|
if ($file === false) { throw new RuntimeException('Hospital handoff storage unavailable'); }
|
|
try {
|
|
chmod($path, 0600);
|
|
$json = json_encode($record, JSON_THROW_ON_ERROR);
|
|
if (fwrite($file, $json) !== strlen($json) || !fflush($file)) {
|
|
throw new RuntimeException('Hospital handoff storage unavailable');
|
|
}
|
|
} catch (\Throwable $error) {
|
|
fclose($file);
|
|
unlink($path);
|
|
throw $error;
|
|
}
|
|
fclose($file);
|
|
return $ticket;
|
|
});
|
|
}
|
|
|
|
public function consume(string $ticket, string $verifier): array
|
|
{
|
|
if (!preg_match('/^[a-f0-9]{64}$/D', $ticket)
|
|
|| !preg_match('/^[A-Za-z0-9._~-]{43,128}$/D', $verifier)) {
|
|
throw new RuntimeException('Hospital handoff invalid');
|
|
}
|
|
return $this->locked(function () use ($ticket, $verifier): array {
|
|
$path = $this->path($ticket);
|
|
$record = $this->read($path);
|
|
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
|
if ($record === null || !is_int($record['expires'] ?? null) || $record['expires'] <= ($this->clock)()
|
|
|| !is_int($record['session_id'] ?? null) || $record['session_id'] < 1
|
|
|| !is_string($record['token_hash'] ?? null) || !preg_match('/^[a-f0-9]{64}$/D', $record['token_hash'])
|
|
|| !is_string($record['challenge'] ?? null) || !hash_equals($record['challenge'], $challenge)) {
|
|
throw new RuntimeException('Hospital handoff invalid');
|
|
}
|
|
// The global private lock covers open/read/delete, so simultaneous exchanges cannot replay.
|
|
if (!unlink($path)) { throw new RuntimeException('Hospital handoff consumption failed'); }
|
|
return $record;
|
|
});
|
|
}
|
|
|
|
private function read(string $path): ?array
|
|
{
|
|
if (!is_file($path) || is_link($path) || filesize($path) > 4096) { return null; }
|
|
try { $record = json_decode((string) file_get_contents($path), true, 8, JSON_THROW_ON_ERROR); }
|
|
catch (\JsonException $error) { return null; }
|
|
return is_array($record) ? $record : null;
|
|
}
|
|
|
|
private function path(string $ticket): string
|
|
{
|
|
return $this->directory . '/' . hash('sha256', $ticket) . '.json';
|
|
}
|
|
|
|
private function locked(callable $operation): mixed
|
|
{
|
|
if (is_link($this->directory) || (!is_dir($this->directory)
|
|
&& !mkdir($this->directory, 0700, true) && !is_dir($this->directory))) {
|
|
throw new RuntimeException('Hospital handoff storage unavailable');
|
|
}
|
|
chmod($this->directory, 0700);
|
|
$path = $this->directory . '/.lock';
|
|
if (is_link($path)) { throw new RuntimeException('Hospital handoff storage unavailable'); }
|
|
$lock = fopen($path, 'c+b');
|
|
if ($lock === false) { throw new RuntimeException('Hospital handoff storage unavailable'); }
|
|
chmod($path, 0600);
|
|
try {
|
|
if (!flock($lock, LOCK_EX)) { throw new RuntimeException('Hospital handoff storage unavailable'); }
|
|
return $operation();
|
|
} finally { flock($lock, LOCK_UN); fclose($lock); }
|
|
}
|
|
}
|