++ fix richiesta password prima di azzeramento applicazione

This commit is contained in:
2026-07-29 15:27:11 +02:00
parent 588910b893
commit fd15903c9c
4 changed files with 143 additions and 9 deletions

View File

@@ -291,7 +291,8 @@ Scelte non ovvie:
pericolosa (`avviaAzzeramento`) crea *solo* il backup e si ferma in pericolosa (`avviaAzzeramento`) crea *solo* il backup e si ferma in
`attesa-download`; la cancellazione parte da un secondo clic esplicito `attesa-download`; la cancellazione parte da un secondo clic esplicito
(`confermaAzzeramento`), che rifiuta se lo stato non registra un (`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 `streamDownload`, "dopo l'ultimo byte" — **non funziona**: con
`fastcgi_buffering on` (il default, e `docker/nginx/default.conf` non lo `fastcgi_buffering on` (il default, e `docker/nginx/default.conf` non lo
disattiva) nginx accetta l'intera risposta dal processo PHP, che quindi 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, *spedito* l'archivio, non che sia arrivato: quella verifica è dell'operatore,
e per questo la conferma è umana. 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 - **Nell'azzeramento i file si cancellano prima del database.** Svuotare il
disco è l'unico passo che può fallire per cause esterne (permessi sul 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 volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un

View File

@@ -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: `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'è. 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 2. L'operazione **si ferma e aspetta**: controlli il file scaricato, digiti la
con un secondo clic. Fino a lì non viene toccato nulla, e puoi annullare. **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, 3. Solo dopo la conferma vengono eliminati territori, proclamatori,
assegnazioni, campagne, zone, tipologie, anni di servizio, il registro assegnazioni, campagne, zone, tipologie, anni di servizio, il registro
attività e tutti i file caricati; le impostazioni tornano ai default e i 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 territori, miniature, logo) in un unico pacchetto scaricabile e ripristinabile
- Barra di avanzamento reale su backup e ripristino (lavoro svolto, non tempo) - Barra di avanzamento reale su backup e ripristino (lavoro svolto, non tempo)
- Ripristino da pacchetto, con cambio password obbligatorio per tutti gli utenti - Ripristino da pacchetto, con cambio password obbligatorio per tutti gli utenti
- **Zona pericolosa**: azzeramento completo con backup obbligatorio scaricato - **Zona pericolosa**: azzeramento completo con backup obbligatorio scaricato,
e confermato prima di procedere password dell'amministratore e conferma esplicita prima di procedere
- Import ed export XML dell'intero set di dati gestionali (senza i file) - Import ed export XML dell'intero set di dati gestionali (senza i file)
- Conversione di dump SQL legacy in XML compatibile - Conversione di dump SQL legacy in XML compatibile
- Import massivo dei PDF dei territori da cartella o archivio ZIP - Import massivo dei PDF dei territori da cartella o archivio ZIP

View File

@@ -5,6 +5,8 @@ namespace App\Livewire\Settings;
use App\Jobs\CreateBackupArchive; use App\Jobs\CreateBackupArchive;
use App\Jobs\ResetApplication; use App\Jobs\ResetApplication;
use App\Services\BackupOperationState; use App\Services\BackupOperationState;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component; use Livewire\Component;
/** /**
@@ -26,8 +28,26 @@ class BackupManager extends Component
/** Frase da digitare per sbloccare l'azzeramento. */ /** Frase da digitare per sbloccare l'azzeramento. */
public const CONFERMA_AZZERAMENTO = 'AZZERA TUTTO'; 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 = ''; 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 public function mount(): void
{ {
$this->authorize('settings.manage'); $this->authorize('settings.manage');
@@ -79,7 +99,7 @@ class BackupManager extends Component
* resterebbe indistinguibile da uno riuscito. Chiedere all'operatore e' * resterebbe indistinguibile da uno riuscito. Chiedere all'operatore e'
* l'unico controllo che copre anche quel caso. * l'unico controllo che copre anche quel caso.
*/ */
public function confermaAzzeramento(): void public function confermaAzzeramento(string $password = ''): void
{ {
$this->authorize('settings.manage'); $this->authorize('settings.manage');
@@ -97,6 +117,10 @@ class BackupManager extends Component
return; return;
} }
if (! $this->passwordConfermata($password)) {
return;
}
app(BackupOperationState::class)->update($this->operationId, function (array $state) { app(BackupOperationState::class)->update($this->operationId, function (array $state) {
$state['fase'] = 'Azzeramento in avvio'; $state['fase'] = 'Azzeramento in avvio';
$state['processed'] = 0; $state['processed'] = 0;
@@ -110,6 +134,56 @@ class BackupManager extends Component
$this->aggiorna(); $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 * Annulla un azzeramento fermo in attesa di conferma. Il backup resta
* scaricabile: e' comunque un backup valido. * scaricabile: e' comunque un backup valido.

View File

@@ -26,6 +26,23 @@
uploadInCorso: false, uploadInCorso: false,
uploadPercento: 0, uploadPercento: 0,
erroreUpload: '', 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) { inviaRipristino(event) {
const form = event.target; const form = event.target;
@@ -115,6 +132,22 @@
ed è l’ultima possibilità di tornare indietro. ed è l’ultima possibilità di tornare indietro.
</p> </p>
{{-- 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. --}}
<div class="max-w-xs">
<label for="passwordConferma" class="block text-xs font-medium text-amber-900">
Conferma con la tua password
</label>
<input type="password" id="passwordConferma" autocomplete="current-password"
x-model="passwordAzzeramento"
x-on:keydown.enter.prevent="azzera()"
class="mt-1 block w-full rounded-lg border-amber-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm">
@error('passwordConferma') <p class="mt-1 text-xs font-medium text-red-700">{{ $message }}</p> @enderror
</div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<a href="{{ route('backup.file', $operationId) }}" <a href="{{ route('backup.file', $operationId) }}"
style="display:inline-flex;align-items:center;gap:6px;background:#fff;color:#92400e;border:1px solid #d97706;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;"> style="display:inline-flex;align-items:center;gap:6px;background:#fff;color:#92400e;border:1px solid #d97706;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
@@ -122,8 +155,9 @@
</a> </a>
<button type="button" <button type="button"
wire:click="confermaAzzeramento" x-on:click="azzera()"
wire:confirm="Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente." x-bind:disabled="!passwordAzzeramento"
x-bind:style="!passwordAzzeramento ? 'opacity:.5;cursor:not-allowed;' : ''"
style="display:inline-flex;align-items:center;gap:6px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:600;"> style="display:inline-flex;align-items:center;gap:6px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:600;">
Ho salvato il backup, azzera tutto Ho salvato il backup, azzera tutto
</button> </button>
@@ -262,7 +296,7 @@
</p> </p>
<ol class="text-xs text-gray-600 list-decimal list-inside space-y-1"> <ol class="text-xs text-gray-600 list-decimal list-inside space-y-1">
<li><strong>Viene creato e scaricato un backup completo</strong> di tutto ciò che c&rsquo;è adesso.</li> <li><strong>Viene creato e scaricato un backup completo</strong> di tutto ciò che c&rsquo;è adesso.</li>
<li>L&rsquo;operazione si ferma e aspetta: <strong>controlli il file scaricato e confermi</strong>. Fino a quel clic non viene toccato nulla, e puoi annullare.</li> <li>L&rsquo;operazione si ferma e aspetta: <strong>controlli il file scaricato, digiti la tua password e confermi</strong>. Fino a quel clic non viene toccato nulla, e puoi annullare.</li>
<li>Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attività e <strong>tutti i file caricati</strong>.</li> <li>Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attività e <strong>tutti i file caricati</strong>.</li>
<li>Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.</li> <li>Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.</li>
<li><strong>Tutti gli altri utenti vengono eliminati.</strong> Resta solo il tuo account di amministratore, con la password attuale &mdash; come un&rsquo;installazione nuova, che ha un amministratore solo.</li> <li><strong>Tutti gli altri utenti vengono eliminati.</strong> Resta solo il tuo account di amministratore, con la password attuale &mdash; come un&rsquo;installazione nuova, che ha un amministratore solo.</li>