move_detect/cloud/src/Api/ConfigController.php
유창욱 171ef3e1c5 fix(cloud): add nonce-replay + timestamp-window checks to config/heartbeat (M2)
ConfigController::get and HeartbeatController::post verified HMAC but never
checked nonce replay or timestamp freshness, so a captured signed request
(e.g. a replayed heartbeat) could be replayed forever, masking a dead
device. Inject NonceRepository (+Config) into both and add the same
timestamp-window + nonce-remember checks IngestController already applies,
after HMAC verification and before the response/side effect.
2026-07-12 09:19:32 +09:00

39 lines
1.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Api;
use App\Config;
use App\Http\{Request, Response};
use App\Security\Hmac;
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository};
final class ConfigController
{
public function __construct(
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
private NonceRepository $nonces, private Config $cfg,
) {}
public function get(Request $req, int $now): Response
{
$deviceId = (string)$req->query('device_id');
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
$nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? '';
if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
return Response::json(['error' => 'unauthorized'], 401);
}
if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) {
return Response::json(['error' => 'stale'], 401);
}
if ($nonce === '' || $this->nonces->seen($nonce)) {
return Response::json(['error' => 'replay'], 401);
}
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
return Response::json([
'version' => $this->devices->configVersion($deviceId),
'zones' => $this->zones->forDevice($deviceId),
'thresholds' => $this->thresholds->forDevice($deviceId),
], 200);
}
}