Files
zyt/server/app/adminapi/service/iam/IamLoginTransactionStore.php
T

116 lines
4.4 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\service\iam;
use RuntimeException;
/** Private, browser-bound, single-use state. File locks work with the existing PHP-FPM deployment. */
final class IamLoginTransactionStore
{
public function __construct(private string $directory)
{
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('IAM transaction storage unavailable');
}
}
public function put(string $kind, string $value, string $browser, array $payload, int $ttl): void
{
$handle = fopen($this->path($kind, $value), 'x');
if ($handle === false) {
throw new RuntimeException('IAM transaction creation failed');
}
try {
chmod($this->path($kind, $value), 0600);
$data = json_encode(['browser' => hash('sha256', $browser), 'expires' => time() + $ttl, 'payload' => $payload], JSON_THROW_ON_ERROR);
if (fwrite($handle, $data) !== strlen($data)) {
throw new RuntimeException('IAM transaction write failed');
}
} finally {
fclose($handle);
}
// Only expired private IAM files are collected, and never a file held by another request.
if (random_int(1, 20) === 1) {
foreach (array_slice(glob($this->directory . '/*.json') ?: [], 0, 200) as $file) {
if (filemtime($file) >= time() - 1200) {
continue;
}
$lock = @fopen($file, 'r+');
if ($lock !== false) {
if (flock($lock, LOCK_EX | LOCK_NB)) {
@unlink($file);
flock($lock, LOCK_UN);
}
fclose($lock);
}
}
}
}
public function consume(string $kind, string $value, string $browser): array
{
$handle = @fopen($this->path($kind, $value), 'r+');
if ($handle === false) {
throw new RuntimeException('登录状态已失效,请重新发起快捷登录');
}
try {
if (!flock($handle, LOCK_EX)) {
throw new RuntimeException('IAM transaction lock failed');
}
$data = json_decode(stream_get_contents($handle), true);
if (!is_array($data) || ($data['expires'] ?? 0) <= time() || !isset($data['browser'])
|| !hash_equals($data['browser'], hash('sha256', $browser)) || !is_array($data['payload'] ?? null)) {
throw new RuntimeException('登录状态已失效,请重新发起快捷登录');
}
// Truncate under the lock before returning, so previously opened file descriptors cannot replay.
if (!ftruncate($handle, 0) || !fflush($handle)) {
throw new RuntimeException('IAM transaction consumption failed');
}
return $data['payload'];
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
public function allowStart(string $ip): bool
{
$file = $this->directory . '/limit-' . hash('sha256', $ip) . '.json';
$handle = fopen($file, 'c+');
if ($handle === false) {
return false;
}
try {
chmod($file, 0600);
if (!flock($handle, LOCK_EX)) {
return false;
}
$data = json_decode(stream_get_contents($handle), true);
if (!is_array($data) || ($data['expires'] ?? 0) <= time()) {
$data = ['count' => 0, 'expires' => time() + 600];
}
if ($data['count'] >= 20) {
return false;
}
++$data['count'];
rewind($handle);
ftruncate($handle, 0);
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
return fwrite($handle, $encoded) === strlen($encoded) && fflush($handle);
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
private function path(string $kind, string $value): string
{
if (!in_array($kind, ['state', 'ticket'], true) || !preg_match('/^[a-f0-9]{64}$/D', $value)) {
throw new RuntimeException('登录状态无效,请重新发起快捷登录');
}
return $this->directory . '/' . $kind . '-' . hash('sha256', $value) . '.json';
}
}