Files
zyt/server/app/adminapi/service/IdentityHospitalSsoTicketStore.php
T
2026-11-06 13:37:12 +08:00

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); }
}
}