Implementare la 2FA in Laravel
L'autenticazione a due fattori (2FA) aggiunge un livello di sicurezza fondamentale alle applicazioni web. Oltre alla classica combinazione di email e password, l'utente deve fornire un codice temporaneo generato da un'app come Google Authenticator o Authy. In questo articolo vedremo come implementare la 2FA basata su TOTP (Time-based One-Time Password) in un progetto Laravel, passo dopo passo.
Prerequisiti
Per seguire questa guida è necessario disporre di un progetto Laravel funzionante con il sistema di autenticazione già configurato (ad esempio tramite Laravel Breeze o Fortify), una versione di PHP pari o superiore alla 8.1 e Composer installato. È inoltre utile avere un'app di autenticazione sullo smartphone per testare i codici TOTP.
Installazione delle dipendenze
Il pacchetto pragmarx/google2fa-laravel fornisce un'integrazione completa con il protocollo TOTP. Installiamolo insieme a bacon/bacon-qr-code, necessario per generare i QR code che l'utente scannerizzerà con la propria app di autenticazione.
# Installazione dei pacchetti necessari per la 2FA
composer require pragmarx/google2fa-laravel
composer require bacon/bacon-qr-code
Dopo l'installazione, pubblichiamo il file di configurazione del pacchetto:
# Pubblicazione della configurazione
php artisan vendor:publish --provider="PragmaRX\Google2FALaravel\ServiceProvider"
Questo comando crea il file config/google2fa.php, nel quale è possibile personalizzare parametri come la lunghezza del codice OTP, la finestra di tolleranza temporale e il nome dell'applicazione visualizzato nell'app di autenticazione.
Preparazione del database
Dobbiamo aggiungere due colonne alla tabella users: una per memorizzare la chiave segreta TOTP e una per indicare se l'utente ha attivato la 2FA. Creiamo una migrazione dedicata.
# Creazione della migrazione
php artisan make:migration add_two_factor_columns_to_users_table --table=users
Apriamo il file di migrazione generato e definiamo le colonne:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Chiave segreta per la generazione dei codici TOTP
$table->string('two_factor_secret')->nullable()->after('password');
// Indica se l'utente ha completato l'attivazione della 2FA
$table->boolean('two_factor_enabled')->default(false)->after('two_factor_secret');
// Codici di recupero in caso di perdita del dispositivo
$table->text('two_factor_recovery_codes')->nullable()->after('two_factor_enabled');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_enabled',
'two_factor_recovery_codes',
]);
});
}
};
Eseguiamo la migrazione:
# Applicazione della migrazione al database
php artisan migrate
Aggiornamento del modello User
Il modello User deve essere aggiornato per includere le nuove colonne tra gli attributi protetti e per fornire metodi utili alla gestione della 2FA.
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
class User extends Authenticatable
{
use Notifiable;
protected $fillable = [
'name',
'email',
'password',
'two_factor_secret',
'two_factor_enabled',
'two_factor_recovery_codes',
];
protected $hidden = [
'password',
'remember_token',
// Nasconde la chiave segreta dalle serializzazioni JSON
'two_factor_secret',
'two_factor_recovery_codes',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_enabled' => 'boolean',
];
/**
* Verifica se la 2FA è attiva per questo utente.
*/
public function hasTwoFactorEnabled(): bool
{
return $this->two_factor_enabled && !empty($this->two_factor_secret);
}
/**
* Genera un nuovo set di codici di recupero.
*/
public function generateRecoveryCodes(): array
{
$codes = [];
// Genera 8 codici alfanumerici casuali
for ($i = 0; $i < 8; $i++) {
$codes[] = Str::random(10);
}
$this->two_factor_recovery_codes = encrypt(json_encode($codes));
$this->save();
return $codes;
}
/**
* Restituisce i codici di recupero decriptati.
*/
public function getRecoveryCodes(): array
{
if (empty($this->two_factor_recovery_codes)) {
return [];
}
return json_decode(decrypt($this->two_factor_recovery_codes), true);
}
/**
* Consuma un codice di recupero, rimuovendolo dalla lista.
*/
public function useRecoveryCode(string $code): bool
{
$codes = $this->getRecoveryCodes();
$index = array_search($code, $codes);
// Se il codice non esiste, restituisce falso
if ($index === false) {
return false;
}
// Rimuove il codice usato e salva la lista aggiornata
unset($codes[$index]);
$this->two_factor_recovery_codes = encrypt(json_encode(array_values($codes)));
$this->save();
return true;
}
}
Creazione del controller
Il controller gestirà quattro operazioni principali: la visualizzazione della pagina di configurazione con il QR code, l'attivazione della 2FA dopo la verifica del primo codice, la disattivazione e la verifica del codice durante il login.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use PragmaRX\Google2FA\Google2FA;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
class TwoFactorAuthController extends Controller
{
protected Google2FA $engine;
public function __construct()
{
// Inizializza il motore TOTP
$this->engine = new Google2FA();
}
/**
* Mostra la pagina di configurazione con il QR code.
*/
public function showSetupForm(Request $request)
{
$user = $request->user();
// Genera una nuova chiave segreta se non esiste già
if (empty($user->two_factor_secret)) {
$secret = $this->engine->generateSecretKey();
$user->two_factor_secret = encrypt($secret);
$user->save();
} else {
$secret = decrypt($user->two_factor_secret);
}
// Costruisce l'URI per l'app di autenticazione
$qrCodeUrl = $this->engine->getQRCodeUrl(
config('app.name'),
$user->email,
$secret
);
// Genera l'immagine SVG del QR code
$renderer = new ImageRenderer(
new RendererStyle(200),
new SvgImageBackEnd()
);
$writer = new Writer($renderer);
$qrCodeSvg = $writer->writeString($qrCodeUrl);
return view('auth.two-factor-setup', [
'qrCodeSvg' => $qrCodeSvg,
'secret' => $secret,
]);
}
/**
* Attiva la 2FA dopo la verifica del codice iniziale.
*/
public function enable(Request $request)
{
$request->validate([
'one_time_password' => 'required|string|size:6',
]);
$user = $request->user();
$secret = decrypt($user->two_factor_secret);
// Verifica che il codice inserito sia corretto
$isValid = $this->engine->verifyKey($secret, $request->one_time_password);
if (!$isValid) {
return back()->withErrors([
'one_time_password' => 'Il codice inserito non è valido. Riprova.',
]);
}
// Attiva la 2FA e genera i codici di recupero
$user->two_factor_enabled = true;
$user->save();
$recoveryCodes = $user->generateRecoveryCodes();
return view('auth.two-factor-recovery-codes', [
'recoveryCodes' => $recoveryCodes,
]);
}
/**
* Disattiva la 2FA per l'utente corrente.
*/
public function disable(Request $request)
{
$request->validate([
'current_password' => 'required|current_password',
]);
$user = $request->user();
// Resetta tutti i campi relativi alla 2FA
$user->two_factor_secret = null;
$user->two_factor_enabled = false;
$user->two_factor_recovery_codes = null;
$user->save();
return redirect()
->route('profile.edit')
->with('status', 'Autenticazione a due fattori disattivata.');
}
/**
* Mostra il form di verifica del codice durante il login.
*/
public function showChallenge()
{
return view('auth.two-factor-challenge');
}
/**
* Verifica il codice TOTP o un codice di recupero durante il login.
*/
public function verifyChallenge(Request $request)
{
$request->validate([
'one_time_password' => 'nullable|string',
'recovery_code' => 'nullable|string',
]);
// Recupera l'ID utente dalla sessione
$userId = $request->session()->get('two_factor_user_id');
if (!$userId) {
return redirect()->route('login');
}
$user = \App\Models\User::findOrFail($userId);
// Tentativo di verifica con codice TOTP
if ($request->filled('one_time_password')) {
$secret = decrypt($user->two_factor_secret);
$isValid = $this->engine->verifyKey($secret, $request->one_time_password);
if (!$isValid) {
return back()->withErrors([
'one_time_password' => 'Codice non valido.',
]);
}
}
// Tentativo di verifica con codice di recupero
elseif ($request->filled('recovery_code')) {
if (!$user->useRecoveryCode($request->recovery_code)) {
return back()->withErrors([
'recovery_code' => 'Codice di recupero non valido.',
]);
}
} else {
return back()->withErrors([
'one_time_password' => 'Inserisci un codice di verifica o un codice di recupero.',
]);
}
// Pulisce la sessione e autentica l'utente
$request->session()->forget('two_factor_user_id');
Auth::login($user, $request->session()->get('two_factor_remember', false));
$request->session()->forget('two_factor_remember');
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}
}
Middleware di verifica
Per intercettare il flusso di login e reindirizzare l'utente alla pagina di verifica del codice, creiamo un middleware dedicato. Questo middleware si inserisce dopo l'autenticazione standard e prima dell'accesso alle rotte protette.
# Creazione del middleware
php artisan make:middleware TwoFactorAuthentication
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TwoFactorAuthentication
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
// Se l'utente non ha la 2FA attiva, prosegui normalmente
if (!$user || !$user->hasTwoFactorEnabled()) {
return $next($request);
}
// Se la 2FA è già stata verificata in questa sessione, prosegui
if ($request->session()->get('two_factor_verified')) {
return $next($request);
}
// Salva l'utente in sessione e reindirizza alla verifica
$request->session()->put('two_factor_user_id', $user->id);
// Effettua il logout temporaneo
auth()->logout();
return redirect()->route('two-factor.challenge');
}
}
Registriamo il middleware nel file bootstrap/app.php (Laravel 11) oppure nel kernel HTTP per le versioni precedenti:
<?php
// File: bootstrap/app.php (Laravel 11+)
use App\Http\Middleware\TwoFactorAuthentication;
use Illuminate\Foundation\Application;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
)
->withMiddleware(function ($middleware) {
// Aggiunge la verifica 2FA alle rotte autenticate
$middleware->appendToGroup('auth', TwoFactorAuthentication::class);
})
->create();
Definizione delle rotte
Le rotte vanno suddivise in due gruppi: quelle protette da autenticazione per la configurazione e la disattivazione, e quelle accessibili durante il flusso di challenge.
<?php
use App\Http\Controllers\TwoFactorAuthController;
use Illuminate\Support\Facades\Route;
// Rotte per la configurazione (utente già autenticato)
Route::middleware('auth')->prefix('two-factor')->group(function () {
Route::get('/setup', [TwoFactorAuthController::class, 'showSetupForm'])
->name('two-factor.setup');
Route::post('/enable', [TwoFactorAuthController::class, 'enable'])
->name('two-factor.enable');
Route::post('/disable', [TwoFactorAuthController::class, 'disable'])
->name('two-factor.disable');
});
// Rotte per la verifica durante il login (senza middleware auth)
Route::middleware('guest')->group(function () {
Route::get('/two-factor-challenge', [TwoFactorAuthController::class, 'showChallenge'])
->name('two-factor.challenge');
Route::post('/two-factor-challenge', [TwoFactorAuthController::class, 'verifyChallenge'])
->name('two-factor.verify');
});
Creazione delle viste Blade
Sono necessarie tre viste: la pagina di configurazione con il QR code, la pagina di visualizzazione dei codici di recupero e la pagina di challenge durante il login.
Pagina di configurazione
{{-- resources/views/auth/two-factor-setup.blade.php --}}
@extends('layouts.app')
@section('content')
<h2>Configura l'autenticazione a due fattori</h2>
<p>Scansiona il QR code con la tua app di autenticazione
(Google Authenticator, Authy, ecc.)</p>
{{-- Mostra il QR code come immagine SVG inline --}}
{!! $qrCodeSvg !!}
<p>Se non riesci a scansionare il QR code, inserisci manualmente
questa chiave nella tua app:</p>
<code>{{ $secret }}</code>
<form method="POST" action="{{ route('two-factor.enable') }}">
@csrf
<label for="one_time_password">
Inserisci il codice a 6 cifre generato dall'app
</label>
<input
type="text"
id="one_time_password"
name="one_time_password"
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
autocomplete="one-time-code"
required
>
@error('one_time_password')
<p>{{ $message }}</p>
@enderror
<button type="submit">Attiva la 2FA</button>
</form>
@endsection
Pagina dei codici di recupero
{{-- resources/views/auth/two-factor-recovery-codes.blade.php --}}
@extends('layouts.app')
@section('content')
<h2>Codici di recupero</h2>
<p>Conserva questi codici in un luogo sicuro. Ciascun codice
può essere usato una sola volta per accedere al tuo account
nel caso in cui perdessi l'accesso al dispositivo di autenticazione.</p>
<ul>
{{-- Elenca ogni codice di recupero --}}
@foreach ($recoveryCodes as $code)
<li><code>{{ $code }}</code></li>
@endforeach
</ul>
<p><strong>Attenzione:</strong> questi codici non verranno
più mostrati. Salvali adesso.</p>
<a href="{{ route('dashboard') }}">Ho salvato i codici, continua</a>
@endsection
Pagina di challenge
{{-- resources/views/auth/two-factor-challenge.blade.php --}}
@extends('layouts.app')
@section('content')
<h2>Verifica a due fattori</h2>
<p>Inserisci il codice generato dalla tua app di autenticazione
per completare l'accesso.</p>
<form method="POST" action="{{ route('two-factor.verify') }}">
@csrf
<label for="one_time_password">Codice di verifica</label>
<input
type="text"
id="one_time_password"
name="one_time_password"
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
autocomplete="one-time-code"
>
@error('one_time_password')
<p>{{ $message }}</p>
@enderror
<p>Oppure usa un codice di recupero:</p>
<label for="recovery_code">Codice di recupero</label>
<input
type="text"
id="recovery_code"
name="recovery_code"
>
@error('recovery_code')
<p>{{ $message }}</p>
@enderror
<button type="submit">Verifica</button>
</form>
@endsection
Personalizzazione del flusso di login
Per integrare la 2FA nel processo di login esistente, è necessario intervenire sul controller di autenticazione. Se si utilizza Laravel Breeze, il file da modificare è AuthenticatedSessionController. L'idea è intercettare il login prima che la sessione venga creata e, se l'utente ha la 2FA attiva, reindirizzare al challenge anziché completare l'accesso.
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuthenticatedSessionController extends Controller
{
public function store(LoginRequest $request)
{
$request->authenticate();
$user = Auth::user();
// Se la 2FA è attiva, salva i dati in sessione e reindirizza
if ($user->hasTwoFactorEnabled()) {
// Memorizza l'ID utente per il challenge
$request->session()->put('two_factor_user_id', $user->id);
$request->session()->put('two_factor_remember', $request->boolean('remember'));
// Effettua il logout: l'utente non è ancora verificato
Auth::logout();
return redirect()->route('two-factor.challenge');
}
// Flusso standard senza 2FA
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}
}
Rate limiting e sicurezza
La verifica del codice TOTP deve essere protetta da un rate limiter per prevenire attacchi di forza bruta. Il codice a 6 cifre offre un milione di combinazioni possibili, ma con una finestra temporale di 30 secondi e senza limiti ai tentativi, un attaccante potrebbe avere successo. Aggiungiamo un throttle specifico.
<?php
// File: app/Providers/AppServiceProvider.php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Limita a 5 tentativi al minuto per la verifica 2FA
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by(
$request->session()->get('two_factor_user_id') . '|' . $request->ip()
);
});
}
}
Applichiamo il rate limiter alla rotta di verifica:
// Aggiornamento nella definizione delle rotte
Route::post('/two-factor-challenge', [TwoFactorAuthController::class, 'verifyChallenge'])
->middleware('throttle:two-factor')
->name('two-factor.verify');
Test automatizzati
Verifichiamo il comportamento della 2FA con test che coprano i casi principali: attivazione, login con codice valido, login con codice errato e utilizzo dei codici di recupero.
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class TwoFactorAuthTest extends TestCase
{
use RefreshDatabase;
protected Google2FA $engine;
protected function setUp(): void
{
parent::setUp();
// Prepara il motore TOTP per i test
$this->engine = new Google2FA();
}
public function test_user_can_enable_two_factor(): void
{
$user = User::factory()->create();
$this->actingAs($user);
// Visita la pagina di configurazione
$response = $this->get(route('two-factor.setup'));
$response->assertOk();
// Genera il codice corretto dalla chiave segreta
$secret = decrypt($user->fresh()->two_factor_secret);
$validCode = $this->engine->getCurrentOtp($secret);
// Invia il codice per attivare la 2FA
$response = $this->post(route('two-factor.enable'), [
'one_time_password' => $validCode,
]);
$response->assertOk();
// Verifica che la 2FA sia stata attivata nel database
$this->assertTrue($user->fresh()->two_factor_enabled);
}
public function test_login_requires_two_factor_code(): void
{
$user = User::factory()->create([
'password' => bcrypt('password'),
]);
// Configura la 2FA per l'utente
$secret = $this->engine->generateSecretKey();
$user->two_factor_secret = encrypt($secret);
$user->two_factor_enabled = true;
$user->save();
// Tenta il login con credenziali valide
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
// Deve reindirizzare alla pagina di challenge
$response->assertRedirect(route('two-factor.challenge'));
// Verifica che l'utente non sia ancora autenticato
$this->assertGuest();
}
public function test_invalid_code_is_rejected(): void
{
$user = User::factory()->create();
$secret = $this->engine->generateSecretKey();
$user->two_factor_secret = encrypt($secret);
$user->two_factor_enabled = true;
$user->save();
// Simula la sessione di challenge
$response = $this->withSession([
'two_factor_user_id' => $user->id,
])->post(route('two-factor.verify'), [
'one_time_password' => '000000',
]);
// Deve restituire un errore di validazione
$response->assertSessionHasErrors('one_time_password');
}
public function test_recovery_code_works(): void
{
$user = User::factory()->create();
$secret = $this->engine->generateSecretKey();
$user->two_factor_secret = encrypt($secret);
$user->two_factor_enabled = true;
$user->save();
// Genera i codici di recupero
$codes = $user->generateRecoveryCodes();
$firstCode = $codes[0];
// Usa il primo codice di recupero per accedere
$response = $this->withSession([
'two_factor_user_id' => $user->id,
])->post(route('two-factor.verify'), [
'recovery_code' => $firstCode,
]);
$response->assertRedirect('/dashboard');
// Verifica che il codice usato sia stato rimosso
$remainingCodes = $user->fresh()->getRecoveryCodes();
$this->assertNotContains($firstCode, $remainingCodes);
$this->assertCount(7, $remainingCodes);
}
}
Eseguiamo i test per verificare che tutto funzioni correttamente:
# Esecuzione dei test relativi alla 2FA
php artisan test --filter=TwoFactorAuthTest
Considerazioni finali
L'implementazione che abbiamo costruito copre tutti gli aspetti fondamentali della 2FA basata su TOTP: la generazione della chiave segreta, la visualizzazione del QR code, la verifica dei codici temporanei, i codici di recupero e la protezione contro gli attacchi di forza bruta. Per un ambiente di produzione, si consiglia di valutare alcuni aspetti aggiuntivi.
La crittografia delle chiavi segrete nel database, che abbiamo implementato tramite le funzioni encrypt() e decrypt() di Laravel, dipende dalla chiave APP_KEY dell'applicazione. La perdita o la rotazione di questa chiave renderebbe irrecuperabili tutte le chiavi TOTP memorizzate. Assicurarsi di avere un backup sicuro della chiave dell'applicazione.
I codici di recupero dovrebbero essere mostrati all'utente una sola volta, al momento della generazione. Offrire la possibilità di rigenerarli in caso di necessità, invalidando automaticamente quelli precedenti.
Infine, è buona pratica registrare un evento ogni volta che la 2FA viene attivata, disattivata o utilizzata per il login. Questo consente di mantenere un audit trail completo e di rilevare eventuali accessi sospetti. Laravel offre un sistema di eventi perfettamente adatto a questo scopo attraverso le classi Event e Listener.