++ 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
`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

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:
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

View File

@@ -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.

View File

@@ -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.
</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">
<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;">
@@ -122,8 +155,9 @@
</a>
<button type="button"
wire:click="confermaAzzeramento"
wire:confirm="Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente."
x-on:click="azzera()"
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;">
Ho salvato il backup, azzera tutto
</button>
@@ -262,7 +296,7 @@
</p>
<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>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>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>