diff --git a/HANDOFF.md b/HANDOFF.md index fc72718..39ca0bd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -291,7 +291,8 @@ Scelte non ovvie: pericolosa (`avviaAzzeramento`) crea *solo* il backup e si ferma in `attesa-download`; la cancellazione parte da un secondo clic esplicito (`confermaAzzeramento`), che rifiuta se lo stato non registra un - `servito_at`. Il primo tentativo faceva partire il reset dalla coda dello + `servito_at` e se non riceve la password dell'amministratore. Il primo + tentativo faceva partire il reset dalla coda dello `streamDownload`, "dopo l'ultimo byte" — **non funziona**: con `fastcgi_buffering on` (il default, e `docker/nginx/default.conf` non lo disattiva) nginx accetta l'intera risposta dal processo PHP, che quindi @@ -300,6 +301,29 @@ Scelte non ovvie: *spedito* l'archivio, non che sia arrivato: quella verifica è dell'operatore, e per questo la conferma è umana. +- **La password si richiede al secondo clic, e non passa dallo snapshot.** La + sessione è già autenticata, quindi la password non serve a stabilire *chi* + sia: serve a stabilire che davanti allo schermo ci sia ancora quella persona + — una scheda lasciata aperta su una postazione condivisa è lo scenario contro + cui protegge. Viaggia come **parametro** di `confermaAzzeramento($password)`, + non come proprietà `wire:model`: le proprietà pubbliche di Livewire vengono + serializzate nello snapshot e rispedite al browser a ogni risposta, e una + password non ha motivo di fare quel viaggio di ritorno. + + Attenzione al dettaglio che costa un pomeriggio: la proprietà + `$passwordConferma` **esiste comunque, e resta sempre vuota**. Livewire + scarta dalla risposta ogni errore la cui chiave non corrisponda a una + proprietà del componente (`SupportValidation::dehydrate()` filtra su + `Utils::hasProperty`), quindi senza quella riga `addError('passwordConferma', + …)` sparisce in silenzio: il pulsante sembra non fare nulla, mentre il + controllo sta funzionando. Non collegarla al campo con `wire:model`, o la + password torna nello snapshot. + + Cinque tentativi sbagliati in 5 minuti bloccano l'azione (`RateLimiter`, + chiave per utente). Senza, la richiesta di password su una sessione dirottata + sarebbe solo un rallentamento. Il blocco è sull'operazione, non sull'account: + nessuno resta chiuso fuori dall'applicazione. + - **Nell'azzeramento i file si cancellano prima del database.** Svuotare il disco è l'unico passo che può fallire per cause esterne (permessi sul volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un diff --git a/README.md b/README.md index beec95c..ba873c7 100644 --- a/README.md +++ b/README.md @@ -433,8 +433,10 @@ l'applicazione allo stato di installazione appena eseguita. Va scritto `AZZERA TUTTO` per sbloccare il pulsante, e l'operazione procede in due tempi: 1. Viene creato e scaricato un backup completo di tutto ciò che c'è. -2. L'operazione **si ferma e aspetta**: controlli il file scaricato e confermi - con un secondo clic. Fino a lì non viene toccato nulla, e puoi annullare. +2. L'operazione **si ferma e aspetta**: controlli il file scaricato, digiti la + **tua password** e confermi con un secondo clic. Fino a lì non viene toccato + nulla, e puoi annullare. Dopo cinque password sbagliate l'azzeramento resta + bloccato per 5 minuti (l'accesso all'applicazione no). 3. Solo dopo la conferma vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attività e tutti i file caricati; le impostazioni tornano ai default e i @@ -624,8 +626,8 @@ docker compose --profile dev up -d --build territori, miniature, logo) in un unico pacchetto scaricabile e ripristinabile - Barra di avanzamento reale su backup e ripristino (lavoro svolto, non tempo) - Ripristino da pacchetto, con cambio password obbligatorio per tutti gli utenti -- **Zona pericolosa**: azzeramento completo con backup obbligatorio scaricato - e confermato prima di procedere +- **Zona pericolosa**: azzeramento completo con backup obbligatorio scaricato, + password dell'amministratore e conferma esplicita prima di procedere - Import ed export XML dell'intero set di dati gestionali (senza i file) - Conversione di dump SQL legacy in XML compatibile - Import massivo dei PDF dei territori da cartella o archivio ZIP diff --git a/app/Livewire/Settings/BackupManager.php b/app/Livewire/Settings/BackupManager.php index 6d13bf6..6cebd00 100644 --- a/app/Livewire/Settings/BackupManager.php +++ b/app/Livewire/Settings/BackupManager.php @@ -5,6 +5,8 @@ namespace App\Livewire\Settings; use App\Jobs\CreateBackupArchive; use App\Jobs\ResetApplication; use App\Services\BackupOperationState; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Component; /** @@ -26,8 +28,26 @@ class BackupManager extends Component /** Frase da digitare per sbloccare l'azzeramento. */ public const CONFERMA_AZZERAMENTO = 'AZZERA TUTTO'; + /** Password sbagliate ammesse prima di bloccare l'azzeramento, e per quanto. */ + private const TENTATIVI_PASSWORD = 5; + private const FINESTRA_TENTATIVI = 300; + public string $confermaReset = ''; + /** + * Esiste solo per dare un nome agli errori del campo password: Livewire + * scarta dal messaggio di risposta ogni errore la cui chiave non + * corrisponda a una proprieta' del componente (SupportValidation::dehydrate), + * quindi senza questa riga "Password non corretta" non arriverebbe mai a + * schermo e il pulsante sembrerebbe non fare nulla. + * + * NON collegarla al campo con wire:model: la password viaggia come + * parametro di confermaAzzeramento() proprio per non finire nello + * snapshot, che Livewire rimanda al browser a ogni risposta. Qui resta + * sempre stringa vuota. + */ + public string $passwordConferma = ''; + public function mount(): void { $this->authorize('settings.manage'); @@ -79,7 +99,7 @@ class BackupManager extends Component * resterebbe indistinguibile da uno riuscito. Chiedere all'operatore e' * l'unico controllo che copre anche quel caso. */ - public function confermaAzzeramento(): void + public function confermaAzzeramento(string $password = ''): void { $this->authorize('settings.manage'); @@ -97,6 +117,10 @@ class BackupManager extends Component return; } + if (! $this->passwordConfermata($password)) { + return; + } + app(BackupOperationState::class)->update($this->operationId, function (array $state) { $state['fase'] = 'Azzeramento in avvio'; $state['processed'] = 0; @@ -110,6 +134,56 @@ class BackupManager extends Component $this->aggiorna(); } + /** + * Verifica la password dell'amministratore prima della cancellazione. + * + * A questo punto la sessione e' gia' autenticata, quindi la password non + * serve a stabilire *chi* sia: serve a stabilire che davanti allo schermo + * ci sia ancora quella persona. E' l'ultimo passo prima di + * un'operazione irreversibile, e una scheda lasciata aperta su una + * postazione condivisa e' lo scenario esatto contro cui protegge. + * + * La password arriva come parametro del metodo e non come proprieta' del + * componente: le proprieta' pubbliche di Livewire vengono serializzate + * nello snapshot e rispedite al browser a ogni risposta, e una password + * non ha motivo di fare quel viaggio di ritorno. + */ + private function passwordConfermata(string $password): bool + { + $utente = auth()->user(); + $chiave = 'azzeramento-password:' . $utente->id; + + // Senza un limite, la richiesta di password su una sessione dirottata + // sarebbe solo un rallentamento: si proverebbero le password comuni a + // raffica. Il blocco e' sull'azione, non sull'account, quindi nessuno + // resta chiuso fuori dall'applicazione. + if (RateLimiter::tooManyAttempts($chiave, self::TENTATIVI_PASSWORD)) { + $this->addError('passwordConferma', sprintf( + 'Troppi tentativi. Riprova fra %d secondi.', + RateLimiter::availableIn($chiave) + )); + + return false; + } + + if ($password === '') { + $this->addError('passwordConferma', 'Inserisci la tua password per confermare.'); + + return false; + } + + if (! Hash::check($password, $utente->getAuthPassword())) { + RateLimiter::hit($chiave, self::FINESTRA_TENTATIVI); + $this->addError('passwordConferma', 'Password non corretta.'); + + return false; + } + + RateLimiter::clear($chiave); + + return true; + } + /** * Annulla un azzeramento fermo in attesa di conferma. Il backup resta * scaricabile: e' comunque un backup valido. diff --git a/resources/views/livewire/settings/backup-manager.blade.php b/resources/views/livewire/settings/backup-manager.blade.php index 836fa17..802b16e 100644 --- a/resources/views/livewire/settings/backup-manager.blade.php +++ b/resources/views/livewire/settings/backup-manager.blade.php @@ -26,6 +26,23 @@ uploadInCorso: false, uploadPercento: 0, erroreUpload: '', + passwordAzzeramento: '', + + azzera() { + if (!this.passwordAzzeramento) { + return; + } + + if (!confirm('Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente.')) { + return; + } + + const password = this.passwordAzzeramento; + // Svuotato subito: se la password è sbagliata si ridigita, e nel + // frattempo non resta nel campo di una scheda lasciata aperta. + this.passwordAzzeramento = ''; + $wire.confermaAzzeramento(password); + }, inviaRipristino(event) { const form = event.target; @@ -115,6 +132,22 @@ ed è l’ultima possibilità di tornare indietro.
+ {{-- La password sta in Alpine, non in una proprietà del + componente: le proprietà pubbliche di Livewire tornano + al browser dentro lo snapshot a ogni risposta, e una + password non ha motivo di fare quel viaggio. Passata + come parametro del metodo, viaggia solo in andata. --}} +{{ $message }}
@enderror +