221 lines
7.1 KiB
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;
|
|
}
|
|
}
|