Generare una passphrase in Laravel
Una passphrase è una sequenza di parole casuali usata come credenziale di autenticazione al posto di una password tradizionale. A differenza di una password composta da caratteri casuali, una passphrase è più lunga ma anche più memorabile, poiché sfrutta la capacità umana di ricordare sequenze di parole piuttosto che stringhe arbitrarie di simboli. In questo articolo vedremo come implementare un generatore di passphrase in Laravel, partendo dalla logica di base fino ad arrivare a un servizio riutilizzabile, un comando Artisan e un test automatizzato.
Requisiti e struttura del progetto
L'implementazione si basa su Laravel 10 o versioni successive. Non è necessario installare pacchetti di terze parti: sfrutteremo le funzionalità native del framework, in particolare le collections, il filesystem e il sistema di comandi Artisan. La struttura che costruiremo prevede:
- un dizionario di parole in italiano o inglese memorizzato come file di testo o come array;
- una classe
PassphraseGeneratorche contiene la logica di generazione; - un Service Provider per registrare il generatore come singleton nel container;
- un comando Artisan per generare passphrase da terminale;
- un controller con il relativo endpoint HTTP;
- un test con Pest per verificare il comportamento del generatore.
Il dizionario di parole
Il cuore di ogni generatore di passphrase è il dizionario. Possiamo memorizzarlo come file di testo nella cartella resources, con una parola per riga. Creiamo il file resources/wordlists/italian.txt con alcune centinaia di parole comuni. Per semplicità, nell'esempio usiamo un array inline, ma in un'applicazione reale è preferibile caricare il file da disco per avere un vocabolario più ricco.
La sicurezza della passphrase dipende direttamente dall'entropia, che è funzione del numero di parole nel dizionario e del numero di parole scelte. Con un dizionario di 7.776 parole (come il celebre EFF wordlist) e sei parole, si ottengono circa 77 bit di entropia, un valore considerato robusto per la maggior parte degli scenari.
La classe PassphraseGenerator
Creiamo la classe nel namespace App\Services. La classe riceve come dipendenze il percorso del dizionario e alcune opzioni di configurazione: il numero di parole, il separatore e se applicare la capitalizzazione alla prima lettera di ogni parola.
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use RuntimeException;
class PassphraseGenerator
{
// Percorso al file del dizionario
private string $wordlistPath;
// Numero di parole da includere nella passphrase
private int $wordCount;
// Carattere separatore tra le parole
private string $separator;
// Flag per la capitalizzazione della prima lettera
private bool $capitalize;
// Collezione delle parole caricate dal dizionario
private Collection $words;
public function __construct(
string $wordlistPath,
int $wordCount = 4,
string $separator = '-',
bool $capitalize = false
) {
$this->wordlistPath = $wordlistPath;
$this->wordCount = max(3, $wordCount);
$this->separator = $separator;
$this->capitalize = $capitalize;
$this->words = $this->loadWordlist();
}
// Carica il dizionario dal file e restituisce una collezione
private function loadWordlist(): Collection
{
if (!file_exists($this->wordlistPath)) {
throw new RuntimeException(
"Dizionario non trovato: {$this->wordlistPath}"
);
}
$lines = file($this->wordlistPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (empty($lines)) {
throw new RuntimeException('Il dizionario è vuoto.');
}
return collect($lines)
->map(fn(string $word) => mb_strtolower(trim($word)))
->filter(fn(string $word) => mb_strlen($word) >= 3)
->values();
}
// Genera una singola passphrase casuale
public function generate(): string
{
$selected = $this->words
->random($this->wordCount)
->map(function (string $word): string {
// Applica la capitalizzazione se richiesta
return $this->capitalize
? mb_strtoupper(mb_substr($word, 0, 1)) . mb_substr($word, 1)
: $word;
});
return $selected->implode($this->separator);
}
// Genera un array di passphrase uniche
public function generateMany(int $count): array
{
return collect(range(1, max(1, $count)))
->map(fn() => $this->generate())
->unique()
->values()
->toArray();
}
// Calcola l'entropia approssimativa in bit della passphrase
public function entropyBits(): float
{
$vocabularySize = $this->words->count();
return $this->wordCount * log($vocabularySize, 2);
}
// Restituisce il numero di parole nel dizionario
public function vocabularySize(): int
{
return $this->words->count();
}
}
Il metodo generate() usa Collection::random(), che internamente sfrutta array_rand(). Questo non è crittograficamente sicuro in tutti i contesti. Se l'applicazione richiede il massimo livello di sicurezza, è opportuno sostituire la selezione casuale con random_int(), che utilizza il generatore di numeri casuali sicuro del sistema operativo.
Selezione crittograficamente sicura
Per le applicazioni che richiedono sicurezza crittografica, sostituiamo il metodo generate() con una versione che usa random_int():
// Genera una passphrase con selezione crittograficamente sicura
public function generateSecure(): string
{
$vocabularySize = $this->words->count();
$selected = [];
while (count($selected) < $this->wordCount) {
// Seleziona un indice casuale in modo crittograficamente sicuro
$index = random_int(0, $vocabularySize - 1);
$word = $this->words->get($index);
// Evita duplicati nella stessa passphrase
if (!in_array($word, $selected, true)) {
$selected[] = $this->capitalize
? mb_strtoupper(mb_substr($word, 0, 1)) . mb_substr($word, 1)
: $word;
}
}
return implode($this->separator, $selected);
}
Il Service Provider
Registriamo il generatore come singleton nel container di Laravel, in modo da caricarne il dizionario una sola volta durante il ciclo di vita della richiesta. Creiamo app/Providers/PassphraseServiceProvider.php:
<?php
namespace App\Providers;
use App\Services\PassphraseGenerator;
use Illuminate\Support\ServiceProvider;
class PassphraseServiceProvider extends ServiceProvider
{
public function register(): void
{
// Registra il generatore come singleton configurato
$this->app->singleton(PassphraseGenerator::class, function () {
return new PassphraseGenerator(
wordlistPath: resource_path('wordlists/italian.txt'),
wordCount: config('passphrase.word_count', 4),
separator: config('passphrase.separator', '-'),
capitalize: config('passphrase.capitalize', false)
);
});
}
}
Aggiungiamo il provider all'array providers in config/app.php se si usa Laravel 10, oppure nel file bootstrap/providers.php in Laravel 11 e successivi.
Creiamo anche il file di configurazione config/passphrase.php:
<?php
return [
// Numero di parole nella passphrase generata
'word_count' => env('PASSPHRASE_WORD_COUNT', 4),
// Carattere di separazione tra le parole
'separator' => env('PASSPHRASE_SEPARATOR', '-'),
// Capitalizzazione della prima lettera di ogni parola
'capitalize' => env('PASSPHRASE_CAPITALIZE', false),
];
Il comando Artisan
Un comando Artisan permette di generare passphrase direttamente da terminale, utile per operazioni di amministrazione o script di provisioning. Generiamo il file con:
php artisan make:command GeneratePassphrase
Modifichiamo poi la classe generata in app/Console/Commands/GeneratePassphrase.php:
<?php
namespace App\Console\Commands;
use App\Services\PassphraseGenerator;
use Illuminate\Console\Command;
class GeneratePassphrase extends Command
{
// Firma del comando con le opzioni disponibili
protected $signature = 'passphrase:generate
{--count=1 : Numero di passphrase da generare}
{--words=4 : Numero di parole per passphrase}
{--separator=- : Carattere separatore}
{--capitalize : Capitalizza la prima lettera di ogni parola}
{--secure : Usa la generazione crittograficamente sicura}
{--entropy : Mostra l\'entropia stimata in bit}';
protected $description = 'Genera una o più passphrase casuali dal dizionario configurato';
public function handle(PassphraseGenerator $generator): int
{
$count = (int) $this->option('count');
$showEntropy = $this->option('entropy');
$useSecure = $this->option('secure');
if ($showEntropy) {
// Mostra le informazioni sull'entropia della configurazione attuale
$this->info(sprintf(
'Dizionario: %d parole | Parole per passphrase: %d | Entropia stimata: %.1f bit',
$generator->vocabularySize(),
(int) $this->option('words'),
$generator->entropyBits()
));
$this->newLine();
}
$passphrases = $useSecure
? array_map(fn() => $generator->generateSecure(), range(1, $count))
: $generator->generateMany($count);
foreach ($passphrases as $passphrase) {
$this->line($passphrase);
}
return self::SUCCESS;
}
}
Il comando si utilizza in questo modo:
# Genera una singola passphrase
php artisan passphrase:generate
# Genera cinque passphrase con separatore punto e capitalizzazione
php artisan passphrase:generate --count=5 --separator=. --capitalize
# Genera tre passphrase con modalità sicura e mostra l'entropia
php artisan passphrase:generate --count=3 --secure --entropy
Il controller HTTP
Esponiamo il generatore come endpoint REST. Creiamo app/Http/Controllers/PassphraseController.php:
<?php
namespace App\Http\Controllers;
use App\Services\PassphraseGenerator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PassphraseController extends Controller
{
public function __construct(
private readonly PassphraseGenerator $generator
) {}
// Restituisce una o più passphrase generate
public function generate(Request $request): JsonResponse
{
$validated = $request->validate([
// Numero di passphrase da generare (massimo 20)
'count' => ['sometimes', 'integer', 'min:1', 'max:20'],
'secure' => ['sometimes', 'boolean'],
]);
$count = $validated['count'] ?? 1;
$useSecure = $validated['secure'] ?? false;
if ($count === 1) {
$passphrase = $useSecure
? $this->generator->generateSecure()
: $this->generator->generate();
return response()->json([
'passphrase' => $passphrase,
'entropy' => round($this->generator->entropyBits(), 2),
]);
}
$passphrases = $useSecure
? array_map(fn() => $this->generator->generateSecure(), range(1, $count))
: $this->generator->generateMany($count);
return response()->json([
'passphrases' => $passphrases,
'count' => count($passphrases),
'entropy' => round($this->generator->entropyBits(), 2),
]);
}
}
Registriamo la route in routes/api.php:
use App\Http\Controllers\PassphraseController;
use Illuminate\Support\Facades\Route;
// Endpoint per la generazione di passphrase
Route::get('/passphrase', [PassphraseController::class, 'generate']);
Una chiamata tipica e la relativa risposta:
curl "https://esempio.it/api/passphrase?count=3&secure=true"
{
"passphrases": [
"tavolo-finestra-mattino",
"fiume-collina-nuvola",
"radice-sentiero-estate"
],
"count": 3,
"entropy": 32.47
}
Rate limiting sull'endpoint
Un endpoint che genera token di autenticazione deve essere protetto da abusi. Applichiamo un rate limiter dedicato in app/Providers/AppServiceProvider.php o nel file bootstrap/app.php di Laravel 11:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
// Configura il limite di richieste per l'endpoint di generazione
RateLimiter::for('passphrase', function (Request $request) {
return Limit::perMinute(30)->by($request->ip());
});
Applichiamo il limiter nella definizione della route:
Route::middleware(['throttle:passphrase'])
->get('/passphrase', [PassphraseController::class, 'generate']);
Test con Pest
Scriviamo i test in tests/Unit/PassphraseGeneratorTest.php usando Pest, il framework di test preferito nell'ecosistema Laravel moderno.
<?php
use App\Services\PassphraseGenerator;
// Crea un file dizionario temporaneo per i test
function createTemporaryWordlist(int $wordCount = 100): string
{
$path = sys_get_temp_dir() . '/test_wordlist.txt';
$words = array_map(
fn(int $i) => "parola{$i}",
range(1, $wordCount)
);
file_put_contents($path, implode(PHP_EOL, $words));
return $path;
}
beforeEach(function () {
// Prepara il percorso del dizionario temporaneo
$this->wordlistPath = createTemporaryWordlist(200);
});
afterEach(function () {
// Rimuove il file temporaneo dopo ogni test
if (file_exists($this->wordlistPath)) {
unlink($this->wordlistPath);
}
});
it('genera una passphrase con il numero corretto di parole', function () {
$generator = new PassphraseGenerator($this->wordlistPath, wordCount: 4);
$passphrase = $generator->generate();
$parts = explode('-', $passphrase);
expect($parts)->toHaveCount(4);
});
it('usa il separatore configurato', function () {
$generator = new PassphraseGenerator($this->wordlistPath, separator: '_');
$passphrase = $generator->generate();
expect($passphrase)->toContain('_');
expect($passphrase)->not->toContain('-');
});
it('capitalizza le parole quando richiesto', function () {
$generator = new PassphraseGenerator($this->wordlistPath, capitalize: true);
$passphrase = $generator->generate();
$parts = explode('-', $passphrase);
foreach ($parts as $part) {
// Ogni parola deve iniziare con una lettera maiuscola
expect($part)->toMatch('/^[A-Z]/');
}
});
it('genera passphrase diverse tra loro', function () {
$generator = new PassphraseGenerator($this->wordlistPath, wordCount: 4);
$passphrases = $generator->generateMany(10);
// Con un dizionario di 200 parole, 10 passphrase devono essere uniche
expect(array_unique($passphrases))->toHaveCount(count($passphrases));
});
it('calcola un valore di entropia positivo', function () {
$generator = new PassphraseGenerator($this->wordlistPath, wordCount: 4);
expect($generator->entropyBits())->toBeGreaterThan(0.0);
});
it('lancia un\'eccezione se il dizionario non esiste', function () {
new PassphraseGenerator('/percorso/inesistente/wordlist.txt');
})->throws(RuntimeException::class);
it('genera una passphrase sicura con generateSecure', function () {
$generator = new PassphraseGenerator($this->wordlistPath, wordCount: 4);
$passphrase = $generator->generateSecure();
$parts = explode('-', $passphrase);
expect($parts)->toHaveCount(4);
// Le parole nella stessa passphrase devono essere tutte diverse
expect(array_unique($parts))->toHaveCount(count($parts));
});
it('restituisce il numero corretto di parole nel vocabolario', function () {
$generator = new PassphraseGenerator($this->wordlistPath);
// Il dizionario temporaneo contiene 200 parole, tutte con almeno 3 caratteri
expect($generator->vocabularySize())->toBe(200);
});
Eseguiamo la suite con:
php artisan test --filter PassphraseGenerator
Test dell'endpoint HTTP
Aggiungiamo un test di feature per verificare il comportamento del controller in tests/Feature/PassphraseControllerTest.php:
<?php
use App\Services\PassphraseGenerator;
beforeEach(function () {
// Sostituisce il generatore reale con un mock per i test di feature
$this->mock(PassphraseGenerator::class, function ($mock) {
$mock->shouldReceive('generate')
->andReturn('test-passphrase-mock');
$mock->shouldReceive('generateMany')
->andReturn(['pass-uno', 'pass-due', 'pass-tre']);
$mock->shouldReceive('generateSecure')
->andReturn('secure-passphrase-mock');
$mock->shouldReceive('entropyBits')
->andReturn(40.5);
});
});
it('restituisce una passphrase singola in formato JSON', function () {
$response = $this->getJson('/api/passphrase');
$response
->assertOk()
->assertJsonStructure(['passphrase', 'entropy'])
->assertJsonPath('passphrase', 'test-passphrase-mock');
});
it('restituisce più passphrase quando si specifica count', function () {
$response = $this->getJson('/api/passphrase?count=3');
$response
->assertOk()
->assertJsonStructure(['passphrases', 'count', 'entropy'])
->assertJsonPath('count', 3);
});
it('rifiuta un count superiore al massimo consentito', function () {
$response = $this->getJson('/api/passphrase?count=99');
$response->assertUnprocessable();
});
Integrazione con il sistema di autenticazione
Il generatore di passphrase può essere integrato con la registrazione utente per proporre automaticamente una passphrase sicura come password iniziale. Ad esempio, nel controller di registrazione possiamo iniettare PassphraseGenerator e passare la passphrase generata alla view:
use App\Services\PassphraseGenerator;
use Illuminate\View\View;
// Mostra il form di registrazione con una passphrase suggerita
public function create(PassphraseGenerator $generator): View
{
return view('auth.register', [
'suggestedPassphrase' => $generator->generateSecure(),
]);
}
Nel form Blade, mostriamo il suggerimento come valore predefinito del campo password:
<input
type="text"
name="password"
value="{{ old('password', $suggestedPassphrase) }}"
autocomplete="new-password"
>
Considerazioni finali sulla sicurezza
Prima di mettere in produzione un generatore di passphrase, è opportuno tenere a mente alcuni punti fondamentali. Il dizionario deve essere sufficientemente ampio: con meno di 1.000 parole, anche una passphrase di quattro termini risulta vulnerabile a un attacco a dizionario. Il valore minimo raccomandato è di 4.096 parole, con 7.776 come standard de facto (pari a cinque lanci di un dado a sei facce per parola).
Il numero di parole deve essere adeguato al contesto: quattro parole sono accettabili per scenari a bassa criticità, mentre sei o più parole sono necessarie per credenziali ad alto valore. L'entropia target consigliata è di almeno 60 bit per uso generale e 80 bit per ambienti ad alta sicurezza.
Infine, le passphrase generate non devono mai essere registrate nei log dell'applicazione. In Laravel, è sufficiente aggiungere il campo al parametro $hidden del modello o assicurarsi che il middleware di logging non registri il corpo delle richieste sui percorsi sensibili.