clusev/app/Support/Confirm/ConfirmToken.php

221 lines
7.1 KiB
PHP

<?php
namespace App\Support\Confirm;
use Closure;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use InvalidArgumentException;
use JsonException;
/**
* Server-issued, signed, single-use token that binds one confirm action through a
* mandatory two-phase lifecycle.
*
* The generic ConfirmAction modal (R5) used to carry client-mutable `event` /
* `params` / `auditAction` / `auditTarget`, so an authenticated operator could call
* an apply handler directly (skipping the modal) or substitute the target/audit
* descriptor. A token issued here is the ONLY routing input the modal and the apply
* handler trust:
*
* - the permitted action must be in {@see self::ACTIONS} (server-side allowlist);
* - the payload (event/params/audit descriptor) is sealed by Crypt (APP_KEY MAC),
* so it cannot be forged or retargeted on the wire;
* - lifecycle is two-phase: {@see issue()} mints a `pending` nonce, {@see confirm()}
* (the modal's confirm step) atomically flips it to `confirmed`, and only a
* `confirmed` nonce can be {@see consume()}d by the apply handler. So a direct
* handler call with a freshly-issued (never-confirmed) token is rejected — the
* modal really has to run. Each phase transition happens at most once;
* - the token is bound to the issuing operator (uid), so it cannot be replayed by
* another account.
*
* All accounts are equal admins (no RBAC); this is an integrity/confirmation control,
* not an authorization tier. Server-scoped actions additionally seal the server id —
* the handler must match it against the server it is about to act on.
*/
class ConfirmToken
{
/** The only confirm events the modal may dispatch / audit. */
public const ACTIONS = [
'serviceConfirmed',
'userRemoved',
'userLoggedOut',
'domainChanged',
'channelChanged',
'tlsModeChanged',
'fileConfirmed',
'keyRemoved',
'credentialDeleted',
'firewallRuleDelete',
'webauthnKeyRemoved',
'sessionsLogoutOthers',
'sessionsLogoutAll',
'twoFactorDisabled',
'banCleared',
'bansClearedAll',
'wgPeerRemoved',
'wgGateToggle',
'wgSshGate',
'wgSetPort',
'wgSetSubnet',
];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
private const TTL = 600;
private const CACHE_PREFIX = 'confirm-token:';
private const STATE_PENDING = 'pending';
private const STATE_CONFIRMED = 'confirmed';
/**
* Mint a `pending` token for one confirm action. Called server-side by the opener.
*
* @param array<string, mixed> $params
*
* @throws InvalidArgumentException when $event is not allowlisted
*/
public static function issue(
string $event,
array $params = [],
string $auditAction = '',
?string $auditTarget = null,
?int $serverId = null,
): string {
if (! in_array($event, self::ACTIONS, true)) {
throw new InvalidArgumentException("Unknown confirm action: {$event}");
}
$nonce = (string) Str::uuid();
Cache::put(self::CACHE_PREFIX.$nonce, self::STATE_PENDING, self::TTL);
return Crypt::encryptString(json_encode([
'nonce' => $nonce,
'event' => $event,
'params' => $params,
'auditAction' => $auditAction,
'auditTarget' => $auditTarget,
'serverId' => $serverId,
'uid' => Auth::id(),
], JSON_THROW_ON_ERROR));
}
/**
* The modal's confirm step: atomically flip the nonce `pending` → `confirmed`.
* Proves a human passed through the modal before any apply handler may run.
*
* @return array<string, mixed>
*
* @throws InvalidConfirmToken
*/
public static function confirm(string $token): array
{
$payload = self::decode($token);
self::transition($payload['nonce'], function (string $key) {
if (Cache::get($key) !== self::STATE_PENDING) {
return false;
}
Cache::put($key, self::STATE_CONFIRMED, self::TTL);
return true;
});
return $payload;
}
/**
* The apply handler's gate: a `confirmed` nonce is burned (single-use) and the
* sealed payload returned. $event is the handler's own action; it must match.
*
* @return array<string, mixed>
*
* @throws InvalidConfirmToken
*/
public static function consume(string $token, string $event): array
{
$payload = self::decode($token);
if ($payload['event'] !== $event) {
throw new InvalidConfirmToken('confirm token action mismatch');
}
self::transition($payload['nonce'], function (string $key) {
if (Cache::get($key) !== self::STATE_CONFIRMED) {
return false;
}
Cache::forget($key);
return true;
});
return $payload;
}
/**
* Signature-only decode for DISPLAY (e.g. the audit-target line). Never throws and
* does not touch the lifecycle state — display must not depend on it.
*
* @return array<string, mixed>|null
*/
public static function peek(string $token): ?array
{
try {
return self::decode($token);
} catch (InvalidConfirmToken) {
return null;
}
}
/**
* Run a state transition under a per-nonce lock so two concurrent requests cannot
* both pass the check (Cache::pull is get-then-forget, not atomic). The closure
* returns true on a successful transition; anything else (or a lost lock race) is
* an invalid token.
*
* @param Closure(string): bool $mutate
*
* @throws InvalidConfirmToken
*/
private static function transition(string $nonce, Closure $mutate): void
{
$key = self::CACHE_PREFIX.$nonce;
$ok = Cache::lock($key.':lock', 10)->get(fn () => $mutate($key));
if ($ok !== true) {
throw new InvalidConfirmToken('confirm token expired, already used, or not confirmed');
}
}
/**
* Decrypt + integrity/allowlist/uid check. No lifecycle-state check here.
*
* @return array<string, mixed>
*
* @throws InvalidConfirmToken
*/
private static function decode(string $token): array
{
try {
$payload = json_decode(Crypt::decryptString($token), true, 512, JSON_THROW_ON_ERROR);
} catch (DecryptException|JsonException) {
throw new InvalidConfirmToken('malformed confirm token');
}
if (! is_array($payload)
|| ! is_string($payload['nonce'] ?? null)
|| ! in_array($payload['event'] ?? null, self::ACTIONS, true)
|| ($payload['uid'] ?? null) !== Auth::id()) {
throw new InvalidConfirmToken('invalid confirm token');
}
return $payload;
}
}