notiziephp.it

La trappola del flag active: utenti non convalidati ma autenticati in CakeDC/Users

Traduzione da PHP Weekly
18 Settembre 2026 Tempo di lettura: 8 minuti
La trappola del flag active: utenti non convalidati ma autenticati in CakeDC/Users

Se implementi la convalida email con CakeDC/Users, prima o poi ti imbatti in una domanda che il plugin ti rimanda silenziosamente: cosa dovrebbe succedere quando qualcuno si registra, non clicca mai il link di convalida e poi prova ad accedere?

La risposta onesta è che CakeDC/Users non decide al posto tuo. Out of the box ottieni una colonna nel database, un paio di behavior e una serie di eventi — ma l'esperienza sei tu a doverla assemblare. Se sbagli finisci in uno dei due scenari peggiori: un utente autenticato in silenzio senza aver mai convalidato l'account, oppure un utente che ha digitato la password corretta e si sente rispondere “username o password non corretti”. Nessuno dei due è ciò che vuoi.

Questo articolo spiega perché accade nella v16 e mostra un modo pulito per cablare il flusso usando gli eventi che il plugin già emette — nessun hack al core, nessun intervento chirurgico sullo schema.

Un flag, due significati

Tutto comincia con una singola colonna booleana sulla tabella users: active. Quando la convalida email è attiva, la registrazione crea l'account con active = 0 e lo porta a 1 solo quando l'utente clicca il link nell'email di convalida. Puoi seguirlo in BaseTokenBehavior::_updateActive():

// $user['validated'] is a transient flag set to false during register()
$emailValidated = $user['validated'];
if (!$emailValidated && $validateEmail) {
    $user['active'] = false;      // registered → inactive + token emailed
    $user->updateToken($tokenExpiration);
} else {
    $user['active'] = true;       // clicked the link → active
    $user['activation_date'] = new DateTime();
}

Nota che non esiste una colonna validated separata nel database — $user['validated'] è una proprietà transitoria usata solo durante la registrazione. La verità persistita è active, e sta svolgendo due compiti contemporaneamente:

  • “Questa persona ha confermato la sua email?” — impostato dal flusso di convalida.
  • “Questo account è abilitato?” — la cosa che un admin attiva o disattiva per bannare o sospendere qualcuno.

Questa confusione è la radice di tutto ciò che segue. Tienila a mente; ci torneremo.

Come il finder decide chi esiste

Il login in CakeDC/Users passa attraverso il password identifier di cakephp/authentication, che risolve l'utente tramite un finder. Il finder che scegli cambia completamente la forma del problema:

finderComportamento per un account inattivo (active = 0)
'active'La clausola WHERE active = 1 fa sì che la riga non venga mai trovata. La password non viene mai verificata. L'utente riceve il generico “username o password non corretti” — anche con la password corretta.
'all'La riga viene trovata e la password viene verificata. Con una password corretta l'identifier ha successo — quindi un utente non convalidato è tecnicamente autenticato.

Ecco la parte controintuitiva. È tentante ricorrere a finder => 'active' — gli utenti inattivi non dovrebbero semplicemente non poter accedere? Ma quella scelta produce la seconda esperienza negativa: un utente reale, con la password giusta, la cui unica colpa è non aver cliccato un'email finita probabilmente nello spam, si sente dire che le sue credenziali sono sbagliate. Proverà di nuovo, reimposterà la password e aprirà un ticket al supporto. Non ha modo di sapere qual è il vero problema.

Mito comune: “finder => 'all' serve a prevenire l'enumerazione degli utenti”. Non è vero. Il fallimento del login da form mappa comunque sia “utente non trovato” sia “password sbagliata” sullo stesso messaggio generico (LoginComponentLoader::forForm non collega alcun messaggio distinto per l'account non attivo). Quindi 'active' non fa trapelare nulla — blocca semplicemente anche l'utente legittimo non ancora convalidato, con un messaggio confondente.

Il flusso migliore: autenticare, poi decidere

La mossa che risolve entrambi gli esiti negativi: mantieni finder => 'all' in modo che la password venga verificata, poi intercetta il risultato dopo l'autenticazione e dirama su active. È l'unico punto in cui puoi distinguere “password sbagliata” da “password corretta, ma account non convalidato” — e dare a ciascuno un messaggio veritiero.

CakeDC/Users ti offre l'hook esatto. LoginComponent::afterIdentifyUser() emette Users.Authentication.afterLogin, e se un listener restituisce un array, quell'array diventa il redirect, cortocircuitando il normale redirect di login:

public function afterLogin(EventInterface $event): ?array
{
    $user = $event->getData('user');
    if ($user === null || !empty($user['active'])) {
        return null;                     // active → let normal login proceed
    }
    // Inactive account must never keep a session. The auth middleware
    // already persisted the identity — drop it right back.
    $this->Authentication->logout();
    $this->getRequest()->getSession()->delete('Flash'); // drop the "Welcome" flash
    if (Configure::read('Users.Email.validate')) {
        $this->Flash->error(__(
            'Your account is not validated yet. Click the link in the ' .
            'email we sent you — check spam, or request a new one below.'
        ));
        return ['action' => 'resendTokenValidation'];
    }
    // validation off ⇒ active=0 can only mean an admin disabled the account
    $this->Flash->error(__('Your account is disabled. Please contact an administrator.'));
    return ['action' => 'login'];
}

Due dettagli rendono tutto questo sicuro. Primo, il middleware di autenticazione ha già scritto l'identità nella sessione nel momento in cui la tua action viene eseguita, quindi limitarsi a fare il redirect non basta — devi chiamare $this->Authentication->logout() per eliminarla. Un account non convalidato non dovrebbe mai mantenere una sessione, nemmeno per una singola richiesta. Secondo, LoginComponent accoda un flash di successo “Welcome” prima che questo evento venga emesso, quindi devi ripulirlo prima di impostare il tuo.

Ecco i tre esiti su /login:

CredenzialiactiveRisultato
password corretta1Autenticato. Redirect normale alla dashboard.
password corretta0Sessione eliminata. Redirect alla pagina di reinvio con un messaggio specifico del tipo “convalida il tuo account”.
password sbagliata / utente inesistente—Errore generico. “Username o password non corretti.” Nessuna sessione, nessuna fuga di informazioni per enumerazione.

Perché questo non fa trapelare nulla: il messaggio specifico “non convalidato” compare soltanto dopo una password corretta — cioè a qualcuno che ha già dimostrato che l'account esiste. Password sbagliata e utente sconosciuto continuano a collassare in un unico messaggio generico. Ottieni onestà per gli utenti reali e opacità per gli attaccanti.

Dopo la registrazione: dì loro di controllare l'email

L'altra metà del problema è che chi si è appena registrato non ha idea che stia per arrivare un'email di convalida. Per impostazione predefinita il plugin mostra il flash “please validate your account before log in” e lo deposita sulla schermata di login — dove l'opzione di reinvio non è visibile da nessuna parte. Quindi mandalo in un punto che spieghi il passo successivo.

RegisterTrait::_afterRegister() emette Users.Global.afterRegister. Attenzione: a differenza dell'hook di login, questo accetta solo un valore di ritorno che sia un'istanza di Response — un array viene ignorato. Quindi restituisci una response di redirect, non un array di URL:

public function afterRegister(EventInterface $event): ?Response
{
    if (!Configure::read('Users.Email.validate')) {
        return null;   // nothing to validate → default "log in" redirect stands
    }
    $this->Flash->success(__(
        'Almost done! We sent a validation link to your email. Click it ' .
        'to activate your account — check spam, or request a new link below.'
    ));
    return $this->redirect(['action' => 'resendTokenValidation']);
}

Entrambi i listener arrivano alla stessa destinazione: resendTokenValidation. Quell'action è già fornita dal plugin (in UserValidationTrait) — accetta un'email o uno username, rigenera il token e reinvia l'email di convalida, rifiutandosi se l'account è già attivo. Ti serve solo un template con il tuo tema. Collegare entrambi gli eventi è un lavoro da due righe nell'initialize() del tuo controller:

$this->getEventManager()->on(
    UsersPlugin::EVENT_AFTER_LOGIN,
    fn(EventInterface $e) => $this->afterLogin($e),
);
$this->getEventManager()->on(
    UsersPlugin::EVENT_AFTER_REGISTER,
    fn(EventInterface $e) => $this->afterRegister($e),
);

Attento ai contratti di ritorno: afterLogin deve restituire un array (afterIdentifyUser controlla is_array()). afterRegister deve restituire una Response (_afterRegister controlla instanceof Response). Stesso pattern, due tipi diversi — confonderli ti dà un listener che non fa nulla, in silenzio.

Il problema che hai solo rimandato

Ricordi i due compiti che svolge active? Questo flusso si basa sul significato “email confermata”, ma la colonna non può distinguerlo dal significato “l'admin ha disabilitato questo account”. Entrambi sono active = 0. Il che significa:

Un utente che un admin ha bandito deliberatamente può visitare la pagina di reinvio, richiedere un'email di “convalida”, cliccare il link e riattivare da solo il proprio account. L'action resendTokenValidation controlla solo che l'account non sia già attivo — non può sapere perché è inattivo.

La vera soluzione: separa i concetti. Aggiungi una colonna dedicata enabled (on/off da admin), mantieni active per la conferma email e richiedi entrambe nel tuo finder di login. Ora “mai convalidato” e “bannato” sono stati distinti, e un reinvio non può annullare un ban. Finché non lo fai, offri il reinvio solo quando la convalida email è attiva — e rimanda un account disabilitato (convalida disattivata) direttamente al login con un messaggio neutro, come fa il listener qui sopra.

Per un'app esistente, aggiungere una colonna significa una migration e un ritocco al finder. Per un progetto greenfield è quasi gratis — quindi se stai partendo da zero, separali fin dal primo giorno e salta del tutto la trappola.

La versione breve

  • Mantieni finder => 'all'. Ti serve la password verificata per distinguere “password sbagliata” da “non convalidato”. Non ti costa alcuna protezione dall'enumerazione.
  • Dirama dopo l'autenticazione, non prima. L'evento Users.Authentication.afterLogin è il punto in cui vive la decisione veritiera.
  • Elimina sempre la sessione per un account inattivo — il middleware l'ha già creata.
  • Guida chi si registra. Fai il redirect dopo la registrazione verso una pagina che spiega l'email e offre il reinvio.
  • Non affidare due significati a un solo flag. Una colonna enabled è la differenza tra “riavvia la convalida” e “fai rientrare un utente bannato”.

Niente di tutto questo richiede di fare un fork del plugin. CakeDC/Users ti offre le giunture — gli eventi, lo switch del finder, l'action di reinvio integrata. Ti lascia solo l'ultimo miglio, la parte che i tuoi utenti percepiscono davvero, da costruire.

Realizzato con CakeDC/Users v16 su CakePHP 5. I nomi degli eventi e le firme dei metodi citati qui provengono da quella linea; le major precedenti emettono eventi simili con nomi diversi. Hai notato qualcosa che ho sbagliato? Lascia un commento.

Condividi su: LinkedIn

Altri articoli recenti