141 lines
4.4 KiB
PHP
141 lines
4.4 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Livewire\Concerns\ResolvesOperator;
|
|
use App\Models\VpnPeer;
|
|
use App\Services\Wireguard\ConfigHandoff;
|
|
use App\Services\Wireguard\ConfigVault;
|
|
use App\Services\Wireguard\QrCode;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\Str;
|
|
use LivewireUI\Modal\ModalComponent;
|
|
|
|
/**
|
|
* Retrieving a stored VPN config again, behind the owner's own password.
|
|
*
|
|
* The password is asked for on EVERY retrieval, not once per session: Laravel's
|
|
* password.confirm middleware keeps a 15-minute session stamp, which would
|
|
* authorise unlimited later downloads from an unattended browser — not what
|
|
* "enter your password to download it" means.
|
|
*/
|
|
class VpnConfigAccess extends ModalComponent
|
|
{
|
|
use ResolvesOperator;
|
|
|
|
public string $uuid;
|
|
|
|
public string $name = '';
|
|
|
|
public string $password = '';
|
|
|
|
/** Opaque handle; the plaintext never enters the component snapshot. */
|
|
public ?string $token = null;
|
|
|
|
public bool $showQr = false;
|
|
|
|
public function mount(string $uuid): void
|
|
{
|
|
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
|
|
$this->authorize('downloadConfig', $peer);
|
|
|
|
$this->uuid = $uuid;
|
|
$this->name = $peer->name;
|
|
}
|
|
|
|
/**
|
|
* Every later request re-checks: once revealed, the plaintext sits in the
|
|
* handoff cache for ten minutes, and an open modal would otherwise keep
|
|
* handing it out after the access was revoked or reassigned — exactly the
|
|
* window purgeSecret() exists to close.
|
|
*/
|
|
public function hydrate(): void
|
|
{
|
|
$peer = VpnPeer::withTrashed()->where('uuid', $this->uuid)->first();
|
|
|
|
if ($peer === null || Gate::denies('downloadConfig', $peer)) {
|
|
ConfigHandoff::forget($this->token);
|
|
$this->token = null;
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
public function reveal(): void
|
|
{
|
|
// A retry must not still be showing the previous attempt's complaint.
|
|
$this->resetErrorBag('password');
|
|
|
|
$peer = VpnPeer::query()->where('uuid', $this->uuid)->firstOrFail();
|
|
$this->authorize('downloadConfig', $peer);
|
|
|
|
if (! $operator = $this->currentOperator()) {
|
|
return;
|
|
}
|
|
|
|
// Rate-limited per user, so a stolen session cannot grind the password.
|
|
$key = 'vpn-config:'.$operator->id;
|
|
if (RateLimiter::tooManyAttempts($key, 5)) {
|
|
$this->addError('password', __('vpn.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
|
|
|
|
return;
|
|
}
|
|
|
|
if (! Hash::check($this->password, $operator->password)) {
|
|
RateLimiter::hit($key, 300);
|
|
Log::warning('VPN config download refused: wrong password', [
|
|
'user_id' => $operator->id, 'peer' => $peer->uuid,
|
|
]);
|
|
$this->addError('password', __('vpn.wrong_password'));
|
|
|
|
return;
|
|
}
|
|
|
|
RateLimiter::clear($key);
|
|
|
|
$plaintext = ConfigVault::decrypt((string) $peer->config_secret);
|
|
if ($plaintext === null) {
|
|
// Wrong or rotated key, or a tampered record. Never guess.
|
|
$this->addError('password', __('vpn.config_unreadable'));
|
|
|
|
return;
|
|
}
|
|
|
|
// Counted in the database, not read-modify-written here: two concurrent
|
|
// retrievals would otherwise record one. This is an audit trail, so
|
|
// under-reporting is the one failure mode it must not have.
|
|
VpnPeer::query()->whereKey($peer->getKey())->update([
|
|
'last_downloaded_at' => now(),
|
|
'download_count' => DB::raw('download_count + 1'),
|
|
]);
|
|
|
|
Log::info('VPN config downloaded', ['user_id' => $operator->id, 'peer' => $peer->uuid]);
|
|
|
|
$this->reset('password');
|
|
$this->token = ConfigHandoff::put($plaintext);
|
|
}
|
|
|
|
public function toggleQr(): void
|
|
{
|
|
$this->showQr = ! $this->showQr;
|
|
}
|
|
|
|
public function filename(): string
|
|
{
|
|
return Str::limit(Str::slug($this->name) ?: 'clupilot', 15, '').'.conf';
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
$config = ConfigHandoff::get($this->token);
|
|
|
|
return view('livewire.admin.vpn-config-access', [
|
|
'config' => $config,
|
|
'qrSvg' => $config !== null && $this->showQr ? QrCode::svg($config) : null,
|
|
]);
|
|
}
|
|
}
|