Sessions

Com gestionar sessions a Laravel: configuració, drivers, flash data, regeneració i seguretat.

Què són les sessions?#

El protocol HTTP és, per naturalesa, sense estat. Això vol dir que cada petició que arriba al servidor és completament independent de les anteriors: el servidor no recorda qui ets, què has fet ni si ja t'has autenticat. Sense cap mecanisme per mantenir l'estat entre peticions, cada pàgina que visitessis seria com si fos la primera vegada que interactues amb l'aplicació. No hi hauria login persistent, ni carrets de compra, ni preferències d'usuari.

Les sessions resolen aquest problema creant un pont entre peticions consecutives. Quan un usuari fa la primera petició, Laravel genera un identificador únic de sessió i l'envia al navegador com a cookie. A partir d'aquí, cada petició posterior inclou aquest identificador, i Laravel pot recuperar les dades associades a aquell usuari concret. Les dades de sessió es guarden al servidor (en fitxers, base de dades, Redis o qualsevol altre driver), i el navegador només coneix l'identificador, mai les dades en si.

Gairebé totes les aplicacions web necessiten sessions d'alguna forma. El cas més evident és l'autenticació: quan un usuari fa login, Laravel guarda la informació de l'usuari a la sessió perquè no hagi de tornar a introduir les credencials a cada pàgina. Però les sessions serveixen per a molt més: missatges flash que apareixen després d'una redirecció, preferències temporals com l'idioma o el tema, dades de formularis multi-pas, carrets de compra i qualsevol informació que necessiti persistir entre peticions.

Configuració#

Tota la configuració de sessions es troba al fitxer config/session.php. Aquest fitxer conté dotzenes d'opcions, però les més importants es poden controlar a través de variables d'entorn al fitxer .env. Això permet tenir configuracions diferents per a cada entorn sense tocar el codi: per exemple, utilitzar el driver file en desenvolupament (que no requereix cap servei extern) i redis en producció (que és molt més ràpid i compartible entre servidors).

La variable SESSION_DRIVER determina on es guarden les dades de sessió. La variable SESSION_LIFETIME defineix quants minuts dura una sessió d'inactivitat abans d'expirar. Altres opcions importants inclouen encrypt (per xifrar les dades de sessió), same_site (per protegir contra atacs CSRF) i secure (per assegurar que la cookie només s'envia per HTTPS):

SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=true

Al fitxer config/session.php pots veure totes les opcions disponibles i els seus valors per defecte. Les opcions més rellevants són les següents:

// config/session.php
return [
    'driver' => env('SESSION_DRIVER', 'database'),
 
    'lifetime' => env('SESSION_LIFETIME', 120),
 
    'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
 
    'encrypt' => env('SESSION_ENCRYPT', false),
 
    'cookie' => env('SESSION_COOKIE', 'laravel_session'),
 
    'domain' => env('SESSION_DOMAIN'),
 
    'secure' => env('SESSION_SECURE_COOKIE'),
 
    'same_site' => env('SESSION_SAME_SITE', 'lax'),
 
    'http_only' => true,
];

L'opció expire_on_close fa que la sessió s'elimini quan l'usuari tanca el navegador, independentment del lifetime. L'opció http_only impedeix que JavaScript accedeixi a la cookie de sessió, cosa que protegeix contra atacs XSS. L'opció same_site amb valor lax o strict protegeix contra atacs CSRF assegurant que la cookie només s'envia en peticions del mateix lloc.

Drivers de sessió#

Laravel suporta diversos drivers de sessió, cadascun amb els seus avantatges i casos d'ús. L'elecció del driver adequat depèn de les necessitats de l'aplicació: el rendiment requerit, si s'executa en múltiples servidors, i la infraestructura disponible.

El driver file és el més senzill: guarda cada sessió com un fitxer al directori storage/framework/sessions. Funciona sense cap configuració addicional i és perfecte per a desenvolupament, però no escala bé en producció perquè cada servidor té el seu propi sistema de fitxers. El driver cookie guarda les dades xifrades directament a la cookie del navegador, cosa que elimina la necessitat d'emmagatzematge al servidor, però té una limitació de mida de 4 KB.

El driver database guarda les sessions a una taula de la base de dades. És una bona opció per a aplicacions multi-servidor perquè tots els servidors comparteixen la mateixa base de dades. El driver redis és el més recomanat per a producció: és extremadament ràpid (operacions en microsegons), suporta expiració automàtica de claus i és compartible entre servidors. El driver memcached és similar a Redis en velocitat però no persisteix les dades a disc. Finalment, el driver array només manté les sessions en memòria durant la petició actual i s'utilitza exclusivament per a tests:

// config/session.php
'driver' => env('SESSION_DRIVER', 'database'),
 
// Drivers disponibles:
// file      - Fitxers al directori storage/framework/sessions
// cookie    - Dades xifrades a la cookie (màxim 4KB)
// database  - Taula sessions a la base de dades
// redis     - Redis (recomanat per a producció)
// memcached - Memcached
// dynamodb  - Amazon DynamoDB
// array     - Array en memòria (només per a tests)

Sessions a la base de dades#

Quan l'aplicació s'executa en múltiples servidors darrere d'un load balancer, el driver file no funciona perquè les sessions es guarden al sistema de fitxers local de cada servidor. Si un usuari fa login al servidor A i la següent petició va al servidor B, el servidor B no trobarà la sessió. La solució més senzilla és utilitzar el driver database, que guarda les sessions a una taula compartida per tots els servidors.

Laravel inclou una comanda Artisan per generar la migració necessària. Aquesta comanda crea una taula amb les columnes id (l'identificador de sessió), user_id (per poder associar sessions a usuaris), ip_address, user_agent, payload (les dades serialitzades) i last_activity (per saber quan expira):

php artisan session:table
php artisan migrate

Un cop creada la taula, canvia el driver a database:

SESSION_DRIVER=database

Un avantatge addicional del driver database és que pots consultar les sessions actives directament. Això és útil per mostrar a l'usuari les seves sessions actives (com fa Google amb "Dispositius on has iniciat sessió") o per forçar el tancament de sessions des d'un panell d'administració:

use Illuminate\Support\Facades\DB;
 
// Sessions actives d'un usuari
$sessions = DB::table('sessions')
    ->where('user_id', $user->id)
    ->get();
 
// Tancar totes les sessions d'un usuari excepte l'actual
DB::table('sessions')
    ->where('user_id', $user->id)
    ->where('id', '!=', session()->getId())
    ->delete();

Operacions bàsiques#

Laravel ofereix dues maneres d'interactuar amb les sessions: a través de l'objecte Request o a través del helper global session(). Ambdues opcions són equivalents funcionalment, però l'objecte Request és preferible en controladors perquè facilita el testing (pots simular la sessió al test), mentre que el helper session() és més pràctic en vistes i codi fora de controladors.

Guardar dades#

El mètode put guarda un valor a la sessió associat a una clau. Si la clau ja existeix, el valor anterior es sobreescriu. El helper session() amb un array com a argument fa exactament el mateix. Per afegir elements a un array dins de la sessió sense sobreescriure'l, utilitza el mètode push:

// Guardar un valor
$request->session()->put('key', 'value');
 
// Equivalent amb el helper
session(['key' => 'value']);
 
// Guardar múltiples valors alhora
$request->session()->put([
    'locale' => 'ca',
    'theme' => 'dark',
    'sidebar' => 'collapsed',
]);
 
// Afegir a un array existent a la sessió
$request->session()->push('cart.items', $product->id);

Obtenir dades#

El mètode get recupera un valor de la sessió. Accepta un segon paràmetre que és el valor per defecte si la clau no existeix. El mètode all retorna totes les dades de la sessió com un array associatiu. Això és útil per a depuració, però no s'hauria d'utilitzar en codi de producció perquè exposa tota la informació de la sessió:

// Obtenir un valor (amb valor per defecte)
$value = $request->session()->get('key', 'default');
 
// Equivalent amb el helper
$value = session('key', 'default');
 
// Obtenir totes les dades de la sessió
$allData = $request->session()->all();

Comprovar existència#

Hi ha una diferència subtil però important entre has i exists. El mètode has retorna true si la clau existeix i el seu valor no és null. El mètode exists retorna true si la clau existeix, encara que el seu valor sigui null. Aquesta distinció és rellevant quan guardes valors que poden ser legítimament null:

// Retorna true si la clau existeix i el valor NO és null
if ($request->session()->has('key')) {
    // La clau existeix i té un valor
}
 
// Retorna true si la clau existeix, encara que el valor sigui null
if ($request->session()->exists('key')) {
    // La clau existeix (pot ser null)
}
 
// Retorna true si la clau NO existeix o el seu valor és null
if ($request->session()->missing('key')) {
    // La clau no existeix
}

Obtenir i eliminar#

El mètode pull és una combinació de get i forget: recupera el valor i l'elimina de la sessió en una sola operació. Això és útil per a dades que només necessites llegir un cop, com un token de verificació o una URL de redirecció guardada temporalment:

// Obtenir i eliminar en una sola operació
$token = $request->session()->pull('verification_token');
 
// Equivalent a:
$token = $request->session()->get('verification_token');
$request->session()->forget('verification_token');

Eliminar dades#

El mètode forget elimina una o més claus de la sessió. El mètode flush elimina absolutament totes les dades de la sessió. Utilitza flush amb precaució perquè eliminarà també dades que potser no vols perdre, com les preferències de l'usuari:

// Eliminar una clau
$request->session()->forget('key');
 
// Eliminar múltiples claus
$request->session()->forget(['key1', 'key2', 'key3']);
 
// Eliminar totes les dades de la sessió
$request->session()->flush();

Incrementar i decrementar#

Per a valors numèrics, Laravel ofereix mètodes per incrementar i decrementar directament sense haver de llegir, modificar i guardar manualment:

// Incrementar un valor (per defecte +1)
$request->session()->increment('page_views');
 
// Incrementar en una quantitat específica
$request->session()->increment('points', 5);
 
// Decrementar
$request->session()->decrement('credits');
$request->session()->decrement('credits', 3);

Flash Data#

La flash data és un dels patrons més útils de les sessions. Són dades que només existeixen durant una petició i s'eliminen automàticament després. El cas d'ús més habitual és mostrar missatges d'èxit o error després d'una redirecció: l'usuari envia un formulari, el controlador processa les dades, guarda un missatge flash i redirigeix. A la pàgina de destinació, el missatge es mostra i desapareix automàticament.

Sense flash data, hauries de guardar el missatge a la sessió manualment i recordar eliminar-lo després de mostrar-lo. La flash data automatitza aquest procés: Laravel l'elimina automàticament un cop s'ha llegit a la petició següent.

Guardar flash data#

El mètode flash guarda dades que estaran disponibles només durant la següent petició. El mètode with dels redirects és un drecera convenient per guardar flash data en el moment de redirigir:

// Flash data manual
$request->session()->flash('status', 'Article creat correctament!');
$request->session()->flash('alert-type', 'success');
 
// Flash data amb redirect (més habitual)
return redirect()->route('articles.index')
    ->with('status', 'Article creat correctament!');
 
// Flash data amb múltiples valors
return redirect()->route('articles.index')
    ->with([
        'status' => 'Article creat correctament!',
        'alert-type' => 'success',
    ]);
 
// Flash data amb back() per tornar al formulari
return back()->with('error', 'No s\'ha pogut processar la petició.');

Mantenir flash data#

De vegades necessites que la flash data sobrevisqui més d'una petició. Per exemple, si la pàgina de destinació fa una redirecció addicional (com una comprovació d'autenticació), la flash data es perdria abans que l'usuari la veiés. El mètode reflash manté totes les dades flash per una petició més, i el mètode keep permet mantenir només les claus especificades:

// Mantenir totes les dades flash per una petició més
$request->session()->reflash();
 
// Mantenir només algunes claus
$request->session()->keep(['status', 'alert-type']);

Mostrar flash data a Blade#

A les vistes Blade, pots accedir a la flash data amb el helper session(). El patró més habitual és comprovar si la clau existeix i mostrar el missatge dins d'un element HTML amb l'estil adequat:

{{-- Missatge d'èxit --}}
@if (session('status'))
    <div class="rounded-md bg-green-50 p-4 mb-4">
        <p class="text-sm text-green-800">{{ session('status') }}</p>
    </div>
@endif
 
{{-- Missatge d'error --}}
@if (session('error'))
    <div class="rounded-md bg-red-50 p-4 mb-4">
        <p class="text-sm text-red-800">{{ session('error') }}</p>
    </div>
@endif
 
{{-- Component reutilitzable per a missatges flash --}}
@foreach (['success', 'error', 'warning', 'info'] as $type)
    @if (session($type))
        <div class="alert alert-{{ $type }}">
            {{ session($type) }}
        </div>
    @endif
@endforeach

Per a aplicacions més grans, és recomanable crear un component Blade dedicat als missatges flash. Així no cal repetir el codi a cada vista:

{{-- resources/views/components/flash-messages.blade.php --}}
@props(['types' => ['success', 'error', 'warning', 'info']])
 
@foreach ($types as $type)
    @if (session($type))
        <div
            class="flash-message flash-{{ $type }}"
            x-data="{ show: true }"
            x-show="show"
            x-init="setTimeout(() => show = false, 5000)"
        >
            {{ session($type) }}
            <button @click="show = false">&times;</button>
        </div>
    @endif
@endforeach
 
{{-- Ús al layout --}}
<x-flash-messages />

Regeneració de la sessió#

La regeneració de l'identificador de sessió és una mesura de seguretat fonamental contra atacs de fixació de sessió (session fixation). En un atac de fixació, l'atacant obté un identificador de sessió vàlid (per exemple, visitant l'aplicació) i enganya la víctima perquè utilitzi aquest mateix identificador (per exemple, enviant-li un enllaç amb el session ID a la URL). Si la víctima fa login amb aquest identificador, l'atacant pot accedir al compte de la víctima perquè comparteixen la mateixa sessió.

La solució és regenerar l'identificador de sessió en el moment del login. D'aquesta manera, l'identificador antic (que l'atacant coneixia) queda invalidat i es genera un de nou que l'atacant no coneix. Laravel fa això automàticament quan utilitzes el sistema d'autenticació integrat, però si implementes login personalitzat, has de cridar regenerate manualment:

// Regenerar l'identificador de sessió (manté les dades)
$request->session()->regenerate();
 
// Regenerar i eliminar totes les dades de la sessió
$request->session()->invalidate();

La diferència entre regenerate i invalidate és important. El mètode regenerate genera un nou identificador però conserva totes les dades de la sessió: si l'usuari tenia elements al carret de compra, els manté. El mètode invalidate genera un nou identificador i elimina totes les dades, cosa que és més adequat per al logout o quan vols un "reset" complet de la sessió.

En un controlador de login personalitzat, el patró correcte és autenticar l'usuari i immediatament regenerar la sessió:

public function login(Request $request)
{
    $credentials = $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required'],
    ]);
 
    if (Auth::attempt($credentials)) {
        $request->session()->regenerate();
 
        return redirect()->intended('dashboard');
    }
 
    return back()->withErrors([
        'email' => 'Les credencials no coincideixen.',
    ])->onlyInput('email');
}
 
public function logout(Request $request)
{
    Auth::logout();
 
    $request->session()->invalidate();
    $request->session()->regenerateToken();
 
    return redirect('/');
}

Fixa't que al logout es crida tant invalidate (per eliminar totes les dades de la sessió) com regenerateToken (per regenerar el token CSRF). Això garanteix que la sessió anterior queda completament invalidada i no es pot reutilitzar.

Middleware de sessió#

El middleware StartSession de Laravel és el responsable d'inicialitzar la sessió a cada petició. Llegeix l'identificador de sessió de la cookie, carrega les dades del driver configurat, fa les dades disponibles durant la petició i, al final, guarda els canvis i envia la cookie de resposta. Aquest middleware s'aplica automàticament a totes les rutes web (les definides a routes/web.php).

Les rutes API (definides a routes/api.php) no tenen sessions per defecte perquè les APIs haurien de ser stateless: cada petició s'autentica de manera independent amb tokens. Si per alguna raó necessites sessions a les rutes API, pots afegir el middleware manualment, però és un senyal que potser l'arquitectura no és la correcta.

Bloqueig de sessió#

Les peticions concurrents poden causar condicions de carrera amb les sessions. Si un usuari fa dues peticions simultànies (per exemple, dues pestanyes que carreguen alhora) i ambdues modifiquen la sessió, la última petició que acabi sobreescriu els canvis de la primera. Això pot causar pèrdua de dades de sessió, com elements que desapareixen del carret de compra o preferències que es reverteixen.

Laravel ofereix el bloqueig de sessions per resoldre aquest problema. Amb el bloqueig, la segona petició espera que la primera acabi de treballar amb la sessió abans de continuar. Això garanteix la consistència de les dades, però pot afegir latència si les peticions són lentes. El bloqueig de sessions està disponible amb els drivers redis i database:

Route::post('/cart/add', function () {
    // ...
})->block(
    $lockSeconds = 10,     // Temps màxim d'espera per obtenir el bloqueig
    $waitSeconds = 10      // Temps màxim que es manté el bloqueig
);
 
Route::post('/cart/remove', function () {
    // ...
})->block();

El primer paràmetre de block és el temps màxim en segons que una petició esperarà per obtenir el bloqueig. Si no l'obté en aquest temps, es llança una excepció. El segon paràmetre és el temps màxim que el bloqueig es manté actiu, com a mesura de seguretat per evitar bloquejos infinits si alguna cosa falla.

Drivers personalitzats#

Si cap dels drivers inclosos s'adapta a les necessitats de l'aplicació, pots crear el teu propi driver. Un driver de sessió personalitzat ha d'implementar la interfície SessionHandlerInterface de PHP, que defineix els mètodes bàsics per obrir, tancar, llegir, escriure, destruir i fer garbage collection de sessions:

<?php
 
namespace App\Extensions;
 
use SessionHandlerInterface;
 
class MongoSessionHandler implements SessionHandlerInterface
{
    public function open(string $savePath, string $sessionName): bool
    {
        // Connectar al sistema d'emmagatzematge
        return true;
    }
 
    public function close(): bool
    {
        // Tancar la connexió
        return true;
    }
 
    public function read(string $sessionId): string|false
    {
        // Llegir les dades de sessió pel seu ID
        return '';
    }
 
    public function write(string $sessionId, string $data): bool
    {
        // Escriure les dades de sessió
        return true;
    }
 
    public function destroy(string $sessionId): bool
    {
        // Eliminar una sessió
        return true;
    }
 
    public function gc(int $maxLifetime): int|false
    {
        // Eliminar sessions expirades
        return 0;
    }
}

Per registrar el driver personalitzat, utilitza el mètode extend del Session manager dins del mètode boot d'un service provider:

// app/Providers/AppServiceProvider.php
use App\Extensions\MongoSessionHandler;
use Illuminate\Support\Facades\Session;
 
public function boot(): void
{
    Session::extend('mongo', function ($app) {
        return new MongoSessionHandler;
    });
}

Un cop registrat, pots utilitzar el driver al fitxer .env:

SESSION_DRIVER=mongo

Seguretat de les sessions#

Les sessions són un punt crític de seguretat perquè controlen l'accés als comptes dels usuaris. Hi ha diverses pràctiques que convé seguir per protegir les sessions de la teva aplicació.

Primer, utilitza sempre HTTPS en producció i configura la cookie de sessió com a segura. Això impedeix que l'identificador de sessió es transmeti en clar per la xarxa, cosa que el faria vulnerable a atacs d'intercepció (sniffing):

SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax

Segon, mantingues el lifetime de la sessió raonable. Un lifetime de 120 minuts (el valor per defecte) és adequat per a la majoria d'aplicacions. Per a aplicacions amb dades sensibles (banca, salut), considera un lifetime més curt com 15 o 30 minuts. Per a aplicacions on la comoditat és prioritària (xarxes socials, blocs), pots allargar-lo fins a setmanes o mesos.

Tercer, regenera l'identificador de sessió en moments clau: després del login, després d'un canvi de contrasenya i després d'elevar privilegis. Això minimitza la finestra d'oportunitat d'un atacant que hagi obtingut un identificador de sessió antic:

// Després de canviar la contrasenya
$user->update(['password' => Hash::make($newPassword)]);
$request->session()->regenerate();
 
// Tancar totes les altres sessions del dispositiu
Auth::logoutOtherDevices($currentPassword);

El mètode logoutOtherDevices és especialment útil: tanca totes les sessions de l'usuari excepte la sessió actual. Això és el que passa quan canvies la contrasenya a Gmail i et demana "Vols tancar la sessió a tots els altres dispositius?". Requereix el driver de sessió database per funcionar correctament, ja que necessita poder consultar i eliminar sessions per user_id.