Module 3Approfondir la programmation Laravel
Chapitre 12 sur 17
Gérer la sécurité
Authentification, hachage, chiffrement, Gates, Policies, limitation de débit et API Sanctum.
- ≈ 220 min
- 10 leçons
- 3 vidéos
- 4 exercices
Module 3Approfondir la programmation Laravel
Chapitre 12 sur 17
Authentification, hachage, chiffrement, Gates, Policies, limitation de débit et API Sanctum.
Objectifs
Avec ce chapitre, tu sauras…
Leçon 1
| Risque (module M108) | Protection de Laravel | Ce que tu dois faire |
|---|---|---|
| injection SQL | requêtes préparées (Eloquent, Query Builder) | ne jamais concaténer une saisie dans DB::select, whereRaw, orderBy |
| XSS (script injecté) | {{ }} échappe tout | ne jamais utiliser {!! !!} avec une saisie |
| CSRF | jeton + PreventRequestForgery | @csrf dans chaque formulaire, pas de modification en GET |
| mots de passe | hachage bcrypt ou Argon2 | ne jamais stocker ni afficher un mot de passe en clair |
| vol / fixation de session | cookies chiffrés, HttpOnly, SameSite | régénérer la session à la connexion, HTTPS en production |
| contrôle d'accès défaillant (n° 1 de l'OWASP) | middlewares auth, Gates, Policies | vérifier chaque action sur chaque ressource |
| affectation de masse | $fillable / $guarded | ne jamais mettre role ou est_admin dans $fillable |
| force brute | throttle, RateLimiter | limiter les tentatives de connexion |
| fuite d'informations | page d'erreur détaillée seulement si APP_DEBUG=true | APP_DEBUG=false en production, .env jamais versionné |
| fichiers malveillants | validation mimes, image, noms générés | valider le type et la taille, stocker hors de public/ si privé |
Leçon 2
Leçon 3
Leçon 4
| Instruction | Rôle |
|---|---|
Auth::attempt(['email' => …, 'password' => …], $remember) | cherche l'utilisateur par e-mail, compare le mot de passe avec le hachage, ouvre la session si c'est bon ; renvoie true / false |
$request->session()->regenerate() | change l'identifiant de session après la connexion (contre la fixation de session) |
redirect()->intended(...) | renvoie vers la page que l'utilisateur voulait voir avant d'être redirigé vers la connexion |
onlyInput('email') | réaffiche l'e-mail, jamais le mot de passe |
Leçon 5
Hachage (Hash) | Chiffrement (Crypt) |
|---|---|
| à sens unique : impossible de retrouver la valeur | réversible avec la clé |
| pour les mots de passe | pour des données à relire : numéro de téléphone, jeton d'un service externe |
| bcrypt, Argon2id | AES-256 avec la clé APP_KEY |
on compare (Hash::check) | on déchiffre (Crypt::decryptString) |
Leçon 6
Leçon 7
| Où | Écriture |
|---|---|
| dans un contrôleur | Gate::authorize('update', $commande); (403 si refusé) |
| avec l'utilisateur | if ($request->user()->cannot('view', $commande)) { abort(403); } |
| action sans modèle (créer) | Gate::authorize('create', Commande::class); |
| sur une route | ->middleware('can:update,commande') ou ->can('update', 'commande') |
| dans une vue | @can('update', $commande) … @endcan, @cannot, @canany |
Leçon 8
Réglage (.env de production) | Pourquoi |
|---|---|
APP_ENV=production | active les comportements de production |
APP_DEBUG=false | une erreur n'affiche plus le code, les requêtes ni les variables d'environnement |
APP_URL=https://kitab.ma | les URL générées sont en HTTPS |
SESSION_SECURE_COOKIE=true | le cookie de session n'est envoyé qu'en HTTPS |
APP_KEY unique et secrète | une clé différente du développement |
Leçon 9
Leçon 10
Vidéo · À regarder
EN30 Days to Learn Laravel - Complete 8 Hour Course
Laracasts (Jeffrey Way) · YouTube
Vidéo · À regarder
EN30 Days to Learn Laravel - Complete 8 Hour Course
Laracasts (Jeffrey Way) · YouTube
1« Qui es-tu ? » relève de…
2Auth::attempt() compare le mot de passe saisi…
3Après une connexion réussie, on appelle…
4Un mot de passe doit être…
5La route vers laquelle auth redirige un visiteur s'appelle…
6La policy du modèle Livre s'appelle par convention…
7En Laravel 13, dans un contrôleur, on vérifie une policy avec…
8Une API Sanctum attend le jeton dans l'en-tête…
L'essentiel du chapitre
config/auth.php : guard web (session), provider users (Eloquent). Auth::user(), Auth::id(), Auth::check(), $request->user(), @auth, @guest.guest (inscription, connexion nommée login) et auth (déconnexion en POST) ; User::create($valide) (cast hashed) + Auth::login ; Auth::attempt($identifiants, $remember) + session()->regenerate() + redirect()->intended() ; échec : back()->withErrors()->onlyInput('email') ; déconnexion : Auth::logout(), invalidate(), regenerateToken(). Password::defaults().Hash::make, Hash::check, bcrypt/Argon2id ; jamais md5/sha1, jamais Crypt pour un mot de passe. Chiffrement (réversible, APP_KEY, AES-256) : Crypt::encryptString / decryptString, cast encrypted.Gate::define('nom', fn (User $user) => …) dans AppServiceProvider ; Gate::allows, denies, authorize, can:nom. Policies : make:policy XPolicy --model=X, méthodes viewAny, view, create, update, delete, before() ; découverte automatique ; Gate::authorize('update', $x), $user->can(), ->can('update', 'x'), @can. Vérifier chaque ressource (IDOR).Auth::logout() + invalidate() + regenerateToken()| déconnecte et détruit complètement la session |
$remember = true | « Se souvenir de moi » : un cookie de longue durée garde l'utilisateur connecté (colonne remember_token) |
| dans une Form Request | return $this->user()->can('create', Livre::class); dans authorize() |
| attribut (Laravel 13) | #[Authorize('update', 'commande')] sur la méthode du contrôleur |
le serveur web pointe vers public/ | .env, vendor/ et le code ne sont pas accessibles depuis le web |
Vidéo · À regarder
ENHow NOT to Store Passwords! - Computerphile
Computerphile · YouTube
RateLimiter::for + throttle:nom (429), URL signées + signed, fichiers validés et noms générés, production : APP_DEBUG=false, HTTPS, SESSION_SECURE_COOKIE, composer audit.install:api, HasApiTokens, createToken()->plainTextToken, auth:sanctum, en-tête Authorization: Bearer.L'authentification répond à « qui es-tu ? » : elle vérifie l'identité de l'utilisateur (e-mail et mot de passe, jeton). L'autorisation répond à « as-tu le droit ? » : elle vérifie qu'un utilisateur identifié peut faire une action précise (modifier ce livre, voir cette commande). Un utilisateur authentifié n'est pas autorisé à tout.
La configuration est dans config/auth.php :
web (par défaut) utilise la session et les cookies ; pour une API, on utilise des jetons (Sanctum) ;App\Models\User et la table users.use Illuminate\Support\Facades\Auth;
$user = Auth::user(); // le modèle User connecté, ou null
$id = Auth::id(); // son id, ou null
$ok = Auth::check(); // true si quelqu'un est connecté
$user = auth()->user(); // assistant équivalent
$user = $request->user(); // depuis la requête (le plus courant dans un contrôleur)Dans une vue : @auth … @endauth, @guest … @endguest, et {{ auth()->user()->name }}.
Le moyen le plus rapide d'avoir une authentification complète est de créer le projet avec un kit de démarrage officiel : laravel new kitab puis choisir un kit. Laravel 13 propose quatre kits : React, Vue et Svelte (avec Inertia) et Livewire (avec Blade). Tous utilisent Laravel Fortify côté serveur et fournissent :
Tout le code est copié dans ton projet : tu peux le lire et le modifier. Les fonctionnalités s'activent ou se désactivent dans config/fortify.php.
Beaucoup de tutoriels (et le cours de certains formateurs) utilisent Laravel Breeze (composer require laravel/breeze --dev puis php artisan breeze:install). Breeze a été le kit officiel jusqu'à Laravel 11 ; depuis Laravel 12, il n'est plus mis à jour, remplacé par les nouveaux kits. Si ton projet l'utilise déjà, les principes de ce chapitre restent les mêmes.
Pour comprendre ce que font ces kits, et pour l'examen, il faut savoir écrire l'authentification à la main.
Les routes
use App\Http\Controllers\Auth\ConnexionController;
use App\Http\Controllers\Auth\InscriptionController;
Route::middleware('guest')->group(function () {
Route::get('/inscription', [InscriptionController::class, 'create'])->name('register');
Route::post('/inscription', [InscriptionController::class, 'store']);
Route::get('/connexion', [ConnexionController::class, 'create'])->name('login');
Route::post('/connexion', [ConnexionController::class, 'store'])->middleware('throttle:5,1');
});
Route::post('/deconnexion', [ConnexionController::class, 'destroy'])
->middleware('auth')
->name('logout');Le middleware guest empêche un utilisateur déjà connecté de revoir ces pages ; auth protège la déconnexion. La route de connexion doit s'appeler login : c'est vers elle que le middleware auth redirige les visiteurs (on peut changer la cible avec $middleware->redirectGuestsTo('/connexion') dans bootstrap/app.php). La déconnexion est en POST : un lien GET pourrait être déclenché par un autre site (CSRF).
L'inscription
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class InscriptionController extends Controller
{
public function create(): View
{
return view('auth.inscription');
}
public function store(Request $request): RedirectResponse
{
$valide = $request->validate([
'name' => ['required', 'string', 'max:80'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$user = User::create($valide); // le mot de passe est haché par le cast 'hashed' du modèle
Auth::login($user); // connecte immédiatement le nouvel utilisateur
$request->session()->regenerate();
return redirect()->route('accueil')->with('status', 'Bienvenue sur Kitab !');
}
}Dans le modèle User fourni par Laravel, la colonne password a le cast 'hashed' : User::create() hache le mot de passe automatiquement. Le champ role n'est pas dans $fillable : un nouvel inscrit est toujours client (la valeur par défaut de la colonne).
Password::defaults() applique la règle de mot de passe par défaut (au moins 8 caractères). On peut la renforcer pour toute l'application, dans AppServiceProvider::boot() :
use Illuminate\Validation\Rules\Password;
Password::defaults(fn () => Password::min(10)->letters()->mixedCase()->numbers()->uncompromised());uncompromised() vérifie (de façon anonyme) que le mot de passe ne figure pas dans les fuites de données connues (service Have I Been Pwned).
La connexion et la déconnexion
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class ConnexionController extends Controller
{
public function create(): View
{
return view('auth.connexion');
}
public function store(Request $request): RedirectResponse
{
$identifiants = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (Auth::attempt($identifiants, $request->boolean('remember'))) {
$request->session()->regenerate(); // contre la fixation de session
return redirect()->intended(route('accueil')); // la page demandée avant la connexion
}
return back()
->withErrors(['email' => 'Ces identifiants ne correspondent à aucun compte.'])
->onlyInput('email');
}
public function destroy(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate(); // vide la session
$request->session()->regenerateToken(); // nouveau jeton CSRF
return redirect('/');
}
}Hacher un mot de passe, c'est le transformer avec une fonction à sens unique : on ne peut pas retrouver le mot de passe à partir du résultat (l'empreinte). Pour vérifier une connexion, on hache le mot de passe tapé et on compare les empreintes. Laravel utilise bcrypt par défaut (ou Argon2id, à choisir dans config/hashing.php) : des algorithmes lents volontairement, avec un sel aléatoire, pour rendre les attaques par force brute très coûteuses.
use Illuminate\Support\Facades\Hash;
$empreinte = Hash::make('Kitab-2026!');
// "$2y$12$Qm...": bcrypt, coût 12, sel inclus ; deux appels donnent deux empreintes différentes
Hash::check('Kitab-2026!', $empreinte); // true
Hash::check('kitab-2026!', $empreinte); // false
Hash::needsRehash($empreinte); // true si l'algorithme ou le coût a changé depuisLaravel chiffre avec AES-256 (mode CBC, avec un code d'authentification du message) et la clé APP_KEY du .env.
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
$chiffre = Crypt::encryptString('0612345678'); // "eyJpdiI6Ik..." (différent à chaque appel)
try {
$clair = Crypt::decryptString($chiffre); // "0612345678"
} catch (DecryptException $e) {
// valeur modifiée ou clé différente
}Le plus simple est le cast encrypted dans un modèle : la colonne est chiffrée en base et déchiffrée automatiquement à la lecture (prévoir une colonne text, car la valeur chiffrée est longue).
protected function casts(): array
{
return ['telephone' => 'encrypted'];
}Si tu régénères APP_KEY sur un site en service, toutes les sessions sont perdues et toutes les données chiffrées deviennent illisibles. Pour changer de clé, on place l'ancienne dans APP_PREVIOUS_KEYS : Laravel l'utilise encore pour déchiffrer.
Les rôles
Le plus simple est une colonne role dans users (chapitre 10) et des méthodes d'aide dans le modèle :
public function estAdmin(): bool
{
return $this->role === 'admin';
}
public function estLibraire(): bool
{
return in_array($this->role, ['admin', 'libraire'], true);
}Le middleware role du chapitre 4 protège des groupes de routes. Pour des règles plus fines (« un client ne voit que ses commandes »), Laravel fournit les Gates et les Policies.
Les Gates
Une Gate est une règle d'autorisation sous forme de closure, déclarée dans AppServiceProvider::boot(). Elle reçoit toujours l'utilisateur connecté en premier argument.
use App\Models\User;
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('acceder-admin', fn (User $user) => $user->estLibraire());
Gate::define('gerer-utilisateurs', fn (User $user) => $user->estAdmin());
}use Illuminate\Support\Facades\Gate;
if (Gate::allows('gerer-utilisateurs')) { /* ... */ }
if (Gate::denies('acceder-admin')) { abort(403); }
Gate::authorize('gerer-utilisateurs'); // lève une erreur 403 si refusé
Route::get('/admin/utilisateurs', fn () => 'Comptes')->middleware('can:gerer-utilisateurs');@can('acceder-admin')
<a href="{{ route('admin.dashboard') }}">Administration</a>
@endcanLes Policies
Une Policy regroupe toutes les règles d'autorisation d'un modèle dans une classe. C'est la solution à préférer dès qu'une règle concerne une ressource précise.
php artisan make:policy CommandePolicy --model=Commande<?php
namespace App\Policies;
use App\Models\Commande;
use App\Models\User;
class CommandePolicy
{
/** Les administrateurs ont tous les droits (vérifié avant chaque méthode). */
public function before(User $user, string $ability): ?bool
{
return $user->estAdmin() ? true : null; // null : on laisse décider la méthode
}
public function viewAny(User $user): bool
{
return true; // chacun voit la liste de SES commandes
}
public function view(User $user, Commande $commande): bool
{
return $user->id === $commande->user_id; // seulement le propriétaire
}
public function create(User $user): bool
{
return $user->hasVerifiedEmail();
}
public function update(User $user, Commande $commande): bool
{
return $user->id === $commande->user_id && $commande->statut === 'en_attente';
}
public function delete(User $user, Commande $commande): bool
{
return false; // seul l'admin (via before) peut supprimer
}
}Laravel découvre automatiquement la policy : le modèle Commande correspond à App\Policies\CommandePolicy (même nom + Policy, dans le dossier Policies). Les noms des méthodes (viewAny, view, create, update, delete) correspondent aux actions d'un contrôleur de ressources.
Tableau: Vérifier une autorisation
La limitation de débit
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('connexion', function (Request $request) {
return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip());
});
}Route::post('/connexion', [ConnexionController::class, 'store'])->middleware('throttle:connexion');Au-delà de 5 tentatives par minute pour un même e-mail et une même IP, Laravel répond 429.
Les URL signées
Une URL signée contient une signature calculée avec APP_KEY : si quelqu'un modifie l'URL, la signature ne correspond plus. Idéal pour un lien de désinscription ou de téléchargement envoyé par e-mail.
use Illuminate\Support\Facades\URL;
$lien = URL::temporarySignedRoute('factures.telecharger', now()->addHours(24), ['commande' => $commande->id]);
Route::get('/factures/{commande}', [FactureController::class, 'telecharger'])
->name('factures.telecharger')
->middleware('signed'); // 403 si la signature est absente, fausse ou expiréeLes fichiers envoyés
mimes:jpg,png,pdf) et la taille (max:2048) ;store()), ne jamais utiliser getClientOriginalName() comme nom de fichier ;local (storage/app/private) et les servir par une route protégée qui vérifie l'autorisation.Tableau: La configuration de production
Laravel Sanctum (installé par php artisan install:api) authentifie une API avec des jetons (tokens) envoyés dans l'en-tête Authorization: Bearer …. Le modèle User utilise le trait HasApiTokens.
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
use Illuminate\Validation\ValidationException;
Route::post('/jetons', function (Request $request) {
$request->validate(['email' => 'required|email', 'password' => 'required', 'appareil' => 'required']);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages(['email' => ['Identifiants incorrects.']]);
}
return ['jeton' => $user->createToken($request->appareil)->plainTextToken];
})->middleware('throttle:5,1');
Route::middleware('auth:sanctum')->group(function () {
Route::get('/moi', fn (Request $request) => $request->user());
Route::get('/mes-commandes', fn (Request $request) => $request->user()->commandes()->latest()->get());
});GET /api/mes-commandes HTTP/1.1
Host: kitab.ma
Accept: application/json
Authorization: Bearer 1|q3Xv9kHf2...Le jeton en clair n'est affiché qu'une fois : seule son empreinte est stockée en base (table personal_access_tokens). Pour révoquer : $request->user()->currentAccessToken()->delete();.
Construire l'inscription et la connexion à la main (deux épisodes).
(1) Écris l'inscription, la connexion et la déconnexion à la main (contrôleurs, routes, vues). (2) Limite la connexion à 5 tentatives par minute avec un RateLimiter nommé. (3) Ajoute au menu les liens Connexion / Inscription (invités) ou le nom et le bouton de déconnexion (connectés). (4) Vérifie que le groupe admin du chapitre 4 redirige maintenant vers la page de connexion. (5) Crée la CommandePolicy et la page « Mes commandes » ; vérifie qu'un client qui change l'identifiant dans l'URL reçoit une erreur 403. (6) Renforce Password::defaults().
Pour chaque donnée de Kitab, dis s'il faut la hacher, la chiffrer ou la laisser en clair, et justifie : (1) le mot de passe d'un client ; (2) le numéro de téléphone d'un client, affiché au livreur ; (3) la clé secrète de l'API de paiement CMI ; (4) le titre d'un livre ; (5) la réponse à la question secrète de récupération du compte ; (6) le numéro de la carte d'identité d'un libraire, consulté par les RH.
« Ces identifiants ne correspondent à aucun compte » ne dit pas lequel des deux est faux. Un message « Mot de passe incorrect » confirmerait à un attaquant que l'adresse e-mail existe, ce qui l'aide à cibler ses attaques.
On peut ajouter des conditions à attempt : Auth::attempt([...$identifiants, 'suspendu' => false]) refuse la connexion des comptes suspendus.
La vue de connexion
<x-layout titre="Connexion">
<h1>Connexion</h1>
<form method="POST" action="{{ route('login') }}">
@csrf
<label for="email">Adresse e-mail</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus autocomplete="username">
@error('email') <p class="message-erreur">{{ $message }}</p> @enderror
<label for="password">Mot de passe</label>
<input id="password" type="password" name="password" required autocomplete="current-password">
<label><input type="checkbox" name="remember"> Se souvenir de moi</label>
<button type="submit">Se connecter</button>
<p>Pas de compte ? <a href="{{ route('register') }}">Inscris-toi</a></p>
</form>
</x-layout>@auth
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit">Se déconnecter ({{ auth()->user()->name }})</button>
</form>
@endauthLa vérification de l'adresse e-mail
Pour exiger une adresse e-mail vérifiée, le modèle User implémente l'interface MustVerifyEmail, et les routes concernées reçoivent le middleware verified. Laravel envoie alors un lien de confirmation signé à l'inscription (les kits de démarrage fournissent les pages et les routes nécessaires).
Un mot de passe chiffré peut être déchiffré par quiconque obtient la clé (une fuite du .env suffit) : tous les mots de passe seraient révélés. Un mot de passe se hache, toujours. Et md5() ou sha1() ne conviennent pas : ce sont des fonctions rapides, cassées en quelques secondes par force brute.
use App\Models\Commande;
use Illuminate\Support\Facades\Gate;
public function index(Request $request): View
{
$commandes = $request->user()->commandes()->latest()->paginate(10); // seulement les siennes
return view('commandes.index', compact('commandes'));
}
public function show(Commande $commande): View
{
Gate::authorize('view', $commande); // 403 si ce n'est pas sa commande
return view('commandes.show', ['commande' => $commande->load('livres')]);
}Les anciens tutoriels écrivent $this->authorize('update', $commande). Cette méthode venait du trait AuthorizesRequests de l'ancienne classe de base Controller. Depuis Laravel 11, la classe de base est vide : utilise Gate::authorize(...), ou ajoute toi-même use AuthorizesRequests; dans app/Http/Controllers/Controller.php.
Sans la ligne Gate::authorize('view', $commande), un client connecté qui remplace /commandes/41 par /commandes/42 dans la barre d'adresse verrait la commande d'un autre client. C'est une référence directe non sécurisée (IDOR), une forme de « contrôle d'accès défaillant », le risque n° 1 de l'OWASP. Le middleware auth ne suffit pas : chaque action sur une ressource doit vérifier que l'utilisateur y a droit.
Une policy peut aussi renvoyer un message : return Response::deny('Cette commande est déjà expédiée.'); (classe Illuminate\Auth\Access\Response), ou masquer l'existence de la ressource : Response::denyAsNotFound() (404 au lieu de 403).
Pense aussi à garder les dépendances à jour : composer audit signale les paquets qui ont des failles connues.
Les autorisations en six étapes : Gates, Policies, @can et middleware can.
Pourquoi on hache les mots de passe, avec un sel (Computerphile).
Ce code d'inscription contient cinq problèmes de sécurité. Trouve-les et corrige-les.
Route::get('/inscription', function (Request $request) {
$user = User::create($request->all());
$user->password = md5($request->password);
$user->save();
Auth::login($user);
return redirect($request->input('retour'));
});Écris LivrePolicy : tout le monde (même un visiteur non connecté) peut voir un livre ; les libraires et les admins peuvent créer et modifier ; seul un admin peut supprimer ; un livre qui a déjà été commandé ne peut pas être supprimé, même par un admin. Puis écris : la vérification dans Admin\LivreController@destroy, et le bouton « Supprimer » qui n'apparaît que si l'action est permise. Indice : pour autoriser les visiteurs, le paramètre $user doit être déclaré ?User.