116 lines
4.4 KiB
PHP
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';
|
|
}
|
|
}
|