# Authenticatie

Guards, sessies, Fortify en API-authenticatie met Sanctum in Laravel 13

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-09-30

Tags: laravel, php, auth, api, security

Slides: <https://slides.mctantwerp.be/nl/the-agency/10-authenticatie>

<!-- slide 2 -->

## Agenda

1. Guards en providers
2. Sessies en cookies
3. Starter kits en Fortify
4. Verificatie en wachtwoorden
5. API-authenticatie met Sanctum
6. Brute force afremmen

> **Toelichting:** De eerste twee hoofdstukken gaan over hoe Laravel je herkent, de rest over wat je er in een project mee bouwt. Heb je weinig tijd, doe dan zeker hoofdstuk 2 en 5: sessies en Sanctum komen in bijna elk project terug.

<!-- slide 3 -->

## Na deze les kan je

- Uitleggen hoe Laravel bij elke request opnieuw weet wie er ingelogd is, met een guard, een provider en de sessie
- Een login, een logout en onthoud mij schrijven zonder starter kit, zodat je begrijpt wat de starter kit voor je doet
- Fortify-features aan- en uitzetten, en e-mailverificatie en wachtwoord resetten inschakelen
- Een API beveiligen met Sanctum-tokens en abilities, en een SPA laten inloggen met de sessie-cookie
- Kiezen tussen een token en een cookie voor een app, een koppeling of je eigen frontend
- Een login afremmen met rate limiting

> **Toelichting:** Gebruik dit lijstje als check op het einde. Lukt een punt niet, spring dan terug naar het hoofdstuk in de agenda. De opdracht op het einde test de punten over Sanctum en rate limiting samen.

<!-- slide 4 -->

## 1. Guards en providers

> **Toelichting:** In een gewoon project zie je guards en providers nooit, en dat is net het punt: ze werken op de achtergrond. Je hebt ze nodig zodra iets niet werkt, zoals een API die 401 geeft terwijl je ingelogd bent, of een tweede soort gebruiker.

<!-- slide 5 -->

**HTTP onthoudt niets.**

De server onthoudt niets van de vorige request. Elke request komt binnen als een vreemde, en authenticatie is bij elke request opnieuw uitzoeken wie er voor de deur staat.

> **Toelichting:** Daarom bestaan cookies en tokens: iets dat de browser of de app bij elke request opnieuw meestuurt. Alles in deze les is een variant van dezelfde vraag: wat stuurt de client mee, en hoe zoekt Laravel daarmee de gebruiker op.

<!-- slide 6 -->

### Na Auth::login()

```php
Auth::login($user, remember: true);

return redirect()->intended('dashboard');
```

In de les over single sign-on eindigde de callback op deze regel. Daar staat ook het verschil tussen authenticatie (wie ben je) en autorisatie (wat mag je). Deze les gaat over wat Laravel na die regel doet, bij elke volgende request. Wat een gebruiker mag, is de volgende les.

- **[Single sign-on](/nl/the-agency/02-sso)**: Socialite met Google en Microsoft
- **[Autorisatie](/nl/the-agency/11-autorisatie)**: Gates, policies, rollen en teams

> **Toelichting:** Auth::login() gebruik je als je zelf al weet wie de gebruiker is, zoals na Socialite of na registratie. Auth::attempt() doe je met een e-mail en wachtwoord, en die roept op het einde zelf login aan. De links openen de andere lessen in een nieuw tabblad.

<!-- slide 7 -->

### Guards en providers

**Guard**: Hoe herken ik de gebruiker bij deze request? De web guard kijkt in de sessie, de sanctum guard kijkt ook naar een token in de header.

**Provider**: Waar haal ik die gebruiker vandaan? Standaard Eloquent, met het model App\\Models\\User.

**Authenticatable**: Het contract dat je User-model implementeert, zodat elke guard ermee overweg kan. Zit al in de User van een nieuw project.

> [!NOTE]
> Een guard zegt wie je bent, niet wat je mag. Rollen en rechten zijn autorisatie. Meer in de [docs over authentication](https://laravel.com/docs/13.x/authentication).

> **Toelichting:** Guards bestaan omdat niet elke client hetzelfde meestuurt. Een browser heeft een sessie-cookie, een mobiele app een token, en een script soms HTTP Basic auth. Voor elk van die manieren is er een guard, en ze gebruiken allemaal dezelfde provider om de User op te halen.

<!-- slide 8 -->

### config/auth.php

```php
'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],
],

'providers' => [
    'users' => [
        'driver' => 'eloquent',
        'model' => env('AUTH_MODEL', User::class),
    ],
],
```

1. Een guard heeft een driver en een provider. De web guard gebruikt de sessie: na de login staat je user id in de sessie, en bij elke request leest de guard het daar terug.
2. De provider zegt waar de gebruiker vandaan komt: Eloquent, met het User-model. Bovenaan het bestand staat nog `defaults`, de guard die Laravel neemt als je niets zegt. Dat is `web`.

> **Toelichting:** Je kan de User-class wijzigen met AUTH_MODEL in .env, zonder aan dit bestand te komen. Het bestand bevat ook de passwords-sectie voor wachtwoord resetten, met expire en throttle in minuten. Bij Sanctum komt er geen guard bij in dit bestand: die registreert Sanctum zelf.

<!-- slide 9 -->

### Een tweede guard

Bijna niemand past `config/auth.php` aan. Een tweede guard heb je pas nodig met twee soorten gebruikers in aparte tabellen, elk met een eigen login. Stel: een webshop met klanten in `customers` en medewerkers in `employees`.

- *config/auth.php*

  ```php
  'employee' => [
      'driver' => 'session',
      'provider' => 'employees',
  ],
  ```
- *routes/web.php*

  ```php
  Route::get('backoffice', BackofficeController::class)
      ->middleware('auth:employee');
  ```

> [!TIP]
> Zitten admins en gewone gebruikers in dezelfde users-tabel, dan is het één guard en een rol. Dat is de les over autorisatie.

> **Toelichting:** Twee guards betekent ook twee loginformulieren, twee sets routes en twee keer wachtwoord resetten. Dat is veel werk, dus kies het alleen als de tabellen echt apart moeten, bijvoorbeeld omdat de klant ze uit twee systemen haalt. Vergeet ook geen provider en een passwords-broker per soort gebruiker.

<!-- slide 10 -->

### Wie is er ingelogd

```php
$user = Auth::user();
$id = Auth::id();

$user = $request->user();

if (Auth::check()) { }

Auth::guard('employee')->user();
```

1. De facade geeft de ingelogde gebruiker of zijn id, en null als er niemand is.
2. In een controller liever via de request: je ziet meteen waar de gebruiker vandaan komt, en het is makkelijker te testen.
3. `check()` zegt of er iemand ingelogd is. Meestal heb je het niet nodig, want de `auth` middleware houdt gasten al tegen.
4. Zonder guard neem je de default. Met een tweede guard vraag je die expliciet.

> **Toelichting:** Auth::user() geeft null als niemand ingelogd is. Schrijf je $request->user()->name op een route zonder auth middleware, dan krijg je een fout over een property op null. In Blade heb je auth()->user() en de directives @auth en @guest.

<!-- slide 11 -->

### Routes afschermen

*routes/web.php*

```php
Route::middleware(['auth', 'verified'])->group(function () {
    Route::get('dashboard', DashboardController::class)->name('dashboard');
    Route::resource('projects', ProjectsController::class);
});
```

`auth` laat alleen ingelogde gebruikers door, `verified` alleen wie zijn e-mailadres bevestigd heeft. Zet ze op een groep, dan kan je geen route vergeten die je later aan die groep toevoegt. Met `auth:employee` kies je een andere guard.

> **Toelichting:** Op een API-route zet je auth:sanctum in plaats van auth. Wil je in één route alle auth-middleware zien die erop staat, gebruik dan php artisan route:list -v. Daar zie je per route de hele middleware-lijst.

<!-- slide 12 -->

### Een redirect of een 401

| Niet ingelogd, en | Antwoord |
| --- | --- |
| de request komt van een gewone browser | 302, een redirect naar de route `login` |
| de request verwacht JSON (`Accept: application/json`) | 401 Unauthenticated, als JSON |

> [!WARNING]
> Stuurt je API-client geen `Accept: application/json` mee, dan probeert Laravel te redirecten. Bestaat er geen route `login`, dan krijg je de fout Route \[login\] not defined in plaats van een nette 401.

Een andere bestemming voor gasten zet je in `bootstrap/app.php` met `$middleware->redirectGuestsTo()`.

> **Toelichting:** Laravel beslist op basis van expectsJson(): een Accept-header met json, of een XHR-request. Postman en curl sturen die header niet vanzelf. Zet hem in je Postman-collectie als standaard header, dat bespaart je veel verwarring.

<!-- slide 13 -->

### Je webshop heeft klanten in customers en medewerkers in employees, elk met hun eigen login. Wat voeg je toe in config/auth.php?

- A. Een rol-kolom op users
- B. Een tweede guard met een eigen provider
- C. Een tweede sessie-driver
- D. Een policy per tabel

**Antwoord:** B. Een tweede guard met een eigen provider

Twee soorten gebruikers in twee tabellen: een guard employee met een provider op het Employee-model. Een rol-kolom is verleidelijk, maar die werkt alleen als iedereen in dezelfde users-tabel zit, en dan gaat het over wat iemand mag, niet over wie hij is. Een tweede sessie-driver verandert alleen waar de sessie bewaard wordt.

> **Toelichting:** Een variant die je tegenkomt: één users-tabel met een kolom type, klant of medewerker. Dat is één guard en autorisatie. Twee tabellen met elk een login is zeldzaam, maar dan is een tweede guard de juiste keuze.

<!-- slide 14 -->

## 2. Sessies en cookies

> **Toelichting:** Sessies zijn de standaard voor alles wat in de browser draait: Blade, Livewire en Inertia. Wie begrijpt wat er in de sessie en de cookies zit, lost de helft van de loginproblemen op in een paar minuten.

<!-- slide 15 -->

### Inloggen met een sessie

1. Browser → Laravel, **GET /login**: Laravel start een sessie, zet de sessie-cookie en stopt een CSRF-token in het formulier.
2. Browser → Laravel, **POST /login**: E-mail, wachtwoord en het `_token` van het formulier. Zonder token: 419 Page Expired.
3. Laravel → Database, **User op e-mail**: De provider zoekt de gebruiker. Het wachtwoord zelf gaat nooit naar de database.
4. Database → Laravel, **Password-hash**: Laravel vergelijkt het ingevulde wachtwoord met de bcrypt-hash, met `Hash::check`.
5. Laravel, **regenerate()**: Nieuw sessie-id, en het user id gaat in de sessie. Het oude id is vanaf nu waardeloos.
6. Laravel → Browser, **302 + cookie**: Redirect naar het dashboard, met de nieuwe sessie-cookie: versleuteld, HttpOnly, niet leesbaar voor JavaScript.
7. Browser → Laravel, **GET /dashboard**: De browser stuurt de cookie vanzelf mee. De auth middleware vraagt de web guard wie dit is.
8. Laravel → Database, **Sessie en user**: De guard leest het user id uit de sessie, de provider haalt de User op.
9. Laravel → Browser, **200 dashboard**: `$request->user()` is gevuld. Bij de volgende request begint alles opnieuw vanaf stap 7.

> **Toelichting:** Loop het schema stap voor stap door met de pijltjes. Vergelijk het daarna met je eigen project: open de devtools, tab Network, en log in. Je ziet de POST naar /login, de 302, en de Set-Cookie header. In de sessions-tabel verschijnt een rij met jouw user_id.

<!-- slide 16 -->

### Inloggen in code

*LoginController@store*

```php
$credentials = $request->validate([
    'email' => ['required', 'email'],
    'password' => ['required'],
]);

$remember = $request->boolean('remember');

if (! Auth::attempt($credentials, $remember)) {
    return back()->withErrors([
        'email' => 'Deze gegevens kloppen niet.',
    ])->onlyInput('email');
}
```

1. Valideer eerst. Een leeg veld hoeft niet tot bij de database te geraken.
2. `attempt` zoekt de gebruiker op e-mail en vergelijkt het wachtwoord met de hash. Het hasht zelf, dus nooit `Hash::make` op wat de gebruiker intypt.
3. Dezelfde fout voor een onbekend adres en een fout wachtwoord, anders verklap je wie er een account heeft. `onlyInput` vult het e-mailadres opnieuw in, het wachtwoord niet.

> **Toelichting:** Auth::attempt() accepteert ook extra voorwaarden: ['email' => $email, 'password' => $password, 'active' => 1] logt alleen actieve gebruikers in. Het tweede argument is onthoud mij. Een validatiefout op email laat het formulier de fout naast het juiste veld tonen.

<!-- slide 17 -->

### Na een geslaagde login

```php
$request->session()->regenerate();

return redirect()->intended('dashboard');
```

1. Een nieuw sessie-id. Zonder deze regel kan iemand je een sessie-id opdringen, via een link of een gedeelde computer, en wachten tot jij inlogt. Dat heet [session fixation](https://owasp.org/www-community/attacks/Session_fixation).
2. `intended` stuurt je naar de pagina die je wou openen voor de `auth` middleware je naar de login stuurde. Is er geen, dan naar het dashboard.

> **Toelichting:** In oudere Laravel-versies was regenerate() soms vergeten in zelfgebouwde logins, en daar kwamen session fixation-lekken van. De starter kits en Fortify doen het voor je. Schrijf je zelf een login, bijvoorbeeld voor een API, zet de regel er dan zelf in.

<!-- slide 18 -->

### Wat er in de cookies zit

| Cookie | Wat erin zit | Hoe lang |
| --- | --- | --- |
| `laravel_session` | Een versleuteld sessie-id. De data zelf staat op de server, standaard in de tabel sessions. | `SESSION_LIFETIME`, standaard 120 minuten |
| `remember_web_…` | Je user id en een willekeurige remember_token, versleuteld | 400 dagen, of tot je uitlogt |
| `XSRF-TOKEN` | Het CSRF-token, leesbaar voor JavaScript zodat axios het kan terugsturen | Zolang de sessie |

Laravel versleutelt elke cookie met `APP_KEY`. Een nieuwe key in productie betekent dus dat iedereen uitgelogd is. De naam van de sessie-cookie komt uit `APP_NAME`: een app Klantportaal geeft `klantportaal_session`.

> [!TIP]
> Log in je eigen project in met onthoud mij aangevinkt, open de devtools (Application, Cookies) en zoek deze drie terug.

> **Toelichting:** Wat er precies in de remember cookie zit: je user id, de remember_token en je password-hash, met een | ertussen, en dan versleuteld. Verander je wachtwoord, dan klopt dat laatste stuk niet meer en werkt de cookie ook niet meer.

<!-- slide 19 -->

### Uitloggen

```php
public function destroy(Request $request): RedirectResponse
{
    Auth::logout();

    $request->session()->invalidate();

    $request->session()->regenerateToken();

    return redirect('/');
}
```

1. Haalt de gebruiker uit de sessie en zet een nieuwe `remember_token`. Een onthoud-mij-cookie die nog ergens rondslingert, werkt daarna niet meer.
2. Gooit de rest van de sessie weg: flash data, een winkelmandje. Op een gedeelde computer wil je niets laten liggen.
3. Een nieuw CSRF-token, zodat een formulier van voor de logout niet meer werkt.

> **Toelichting:** Een logout hoort een POST te zijn, geen GET-link. Met een GET kan elke pagina je uitloggen met een simpele img-tag die naar /logout wijst. Fortify registreert /logout als POST.

<!-- slide 20 -->

### Onthoud mij

- Geef `true` mee als tweede argument van `Auth::attempt` of `Auth::login`, meestal uit een checkbox op het formulier.
- Laravel zet dan een willekeurige `remember_token` in de users-tabel en dezelfde waarde in een aparte cookie.
- Is je sessie verlopen, dan logt die cookie je stil opnieuw in en start Laravel een nieuwe sessie.
- `Auth::viaRemember()` zegt of dat net gebeurd is. Handig als je voor iets gevoeligs toch het wachtwoord wil vragen.

> **Toelichting:** Onthoud mij is handig op een eigen laptop, en een risico op een gedeelde computer. Toon de checkbox, maar zet hem niet standaard aan. Voor gevoelige acties kan je met viaRemember() toch het wachtwoord opnieuw vragen.

<!-- slide 21 -->

### Andere sessies stoppen

```php
Route::middleware(['auth', 'auth.session'])->group(function () {
    // ...
});

Auth::logoutOtherDevices($request->password);
```

`logoutOtherDevices` gooit je sessies op andere toestellen eruit en houdt je huidige sessie open. Het vraagt je huidige wachtwoord, en werkt alleen op routes met de `auth.session` middleware. Typisch na een wachtwoordwijziging.

Met de database-sessiedriver heeft de tabel `sessions` een `user_id`, `ip_address` en `user_agent`. Een lijstje "hier ben je ingelogd" bouw je daar snel mee.

> **Toelichting:** De auth.session middleware slaat een hash van je wachtwoord in de sessie op. Verandert het wachtwoord, dan klopt die hash niet meer en wordt de sessie ongeldig. Zo werkt logoutOtherDevices: het hasht je wachtwoord opnieuw en alle andere sessies vallen weg.

<!-- slide 22 -->

### Gevoelige acties

```php
Route::get('settings/tokens', TokensController::class)
    ->middleware(['auth', 'password.confirm'])
    ->name('tokens.index');

$request->session()->passwordConfirmed();
```

1. `password.confirm` vraagt eerst je wachtwoord opnieuw, en stuurt je daarna door. Voor acties waar iemand die even aan je laptop zit niet aan mag: 2FA uitzetten, een API token maken, je account verwijderen.
2. Na een juist wachtwoord zet deze regel een tijdstip in de sessie. Drie uur lang vraagt Laravel het niet opnieuw, in te stellen met `password_timeout`. De starter kits hebben het scherm al.

> **Toelichting:** Password confirmation en 2FA zijn twee verschillende dingen. Confirmation vraagt je wachtwoord opnieuw, 2FA vraagt een code van je gsm bij het inloggen. Fortify kan bij 2FA aanzetten ook een confirmation eisen, met confirmPassword.

<!-- slide 23 -->

### SESSION_LIFETIME staat op 120. Iemand logt in met onthoud mij en komt drie dagen later terug. Wat gebeurt er?

- A. Hij moet opnieuw inloggen
- B. Zijn oude sessie leeft nog
- C. De remember cookie logt hem in, met een nieuwe sessie
- D. Laravel toont 419 Page Expired

**Antwoord:** C. De remember cookie logt hem in, met een nieuwe sessie

De sessie is na 120 minuten zonder activiteit weg, dus de oude sessie leeft zeker niet meer. Maar de remember_web cookie leeft tot 400 dagen: Laravel vindt de gebruiker via id en remember_token terug, logt hem in en start een nieuwe sessie. Opnieuw inloggen is alleen nodig zonder onthoud mij. 419 krijg je bij een formulier met een verlopen CSRF-token, niet bij een gewone GET.

> **Toelichting:** Wil je dat onthoud mij korter werkt, dan kan je de duur aanpassen met setRememberDuration op de guard, in minuten. Voor de meeste projecten is de standaard prima. Wat wel vaak misgaat: een nieuwe APP_KEY in productie, en dan is iedereen uitgelogd, ook met onthoud mij.

<!-- slide 24 -->

## 3. Starter kits en Fortify

> **Toelichting:** In een Agency-project kies je bijna altijd een starter kit, dus deze routes en actions heb je al. Het werk zit in aanpassen: registratie uitzetten, een veld toevoegen, 2FA verplichten. Daarvoor moet je weten waar alles staat.

<!-- slide 25 -->

### Niet zelf bouwen

```console
$ laravel new klantportaal
```

| Kit | Frontend | Auth |
| --- | --- | --- |
| React, Vue, Svelte | Inertia, TypeScript, shadcn | Fortify |
| Livewire | Blade, Livewire 4, Flux UI | Fortify |
| Elke kit, WorkOS-variant | Hetzelfde | WorkOS AuthKit: social login, passkeys, SSO |

Het installer vraagt welke [starter kit](https://laravel.com/docs/13.x/starter-kits) je wil. Alle code komt in je project, dus je past aan wat je wil. Voor een Agency-project begin je hier, en schrijf je geen eigen login.

> **Toelichting:** Welke kit kies je: Livewire als het team vooral Blade en PHP schrijft, React of Vue als er veel interactie in de frontend zit. De auth-backend is bij allemaal Fortify, dus alles in dit hoofdstuk geldt voor elke kit. Een kit updaten doe je niet: de code is van jou vanaf de eerste dag.

<!-- slide 26 -->

### Fortify

| Route | Wat |
| --- | --- |
| `/login` | Formulier tonen en inloggen, met throttling |
| `/register` | Account aanmaken |
| `/forgot-password`, `/reset-password` | Wachtwoord vergeten |
| `/email/verify` | E-mailadres bevestigen |
| `/two-factor-challenge` | De code uit je authenticator-app |

[Fortify](https://laravel.com/docs/13.x/fortify) is headless: routes en controllers, geen schermen. Die komen uit de starter kit. Met `php artisan route:list` zie je wat Fortify in jouw project registreert.

> **Toelichting:** Fortify kan je ook zonder starter kit installeren, met composer require laravel/fortify en php artisan fortify:install. Dan schrijf je de schermen zelf en wijs je ze aan met Fortify::loginView() en dergelijke in FortifyServiceProvider. Voor een SPA zet je views op false in config/fortify.php.

<!-- slide 27 -->

### Features aan en uit

*config/fortify.php*

```php
'features' => [
    Features::registration(),
    Features::resetPasswords(),
    Features::emailVerification(),
    Features::twoFactorAuthentication([
        'confirm' => true,
        'confirmPassword' => true,
    ]),
],
```

1. Registreren. Stel: een klantportaal waar de klant zelf mensen uitnodigt. Haal deze regel weg, anders kan iedereen een account maken.
2. Wachtwoord vergeten.
3. De routes voor e-mailverificatie. Je User moet daarvoor ook `MustVerifyEmail` implementeren.
4. 2FA, waarbij de gebruiker eerst een code bevestigt en zijn wachtwoord opnieuw geeft voor hij 2FA aan- of uitzet.

> **Toelichting:** Zonder registration moet je gebruikers wel ergens aanmaken: een invite-mail met een link om een wachtwoord te kiezen, of een seeder voor de eerste admin. Een handige truc: stuur een gewone reset-link als uitnodiging, dan hoef je geen apart scherm te bouwen.

<!-- slide 28 -->

### Registreren en resetten aanpassen

| app/Actions/Fortify | Wat je er aanpast |
| --- | --- |
| `CreateNewUser.php` | Validatie en User::create bij registreren, zoals een extra veld voor een telefoonnummer |
| `ResetUserPassword.php` | Wat er gebeurt als iemand een nieuw wachtwoord kiest |
| `PasswordValidationRules.php` | De regels voor een wachtwoord, op één plek |

> [!WARNING]
> Zet je een feature uit in een React-, Vue- of Svelte-kit, haal dan ook de links naar die routes uit de frontend. Wayfinder maakt de routes bij de build, en een link naar een route die niet meer bestaat, breekt de build.

> **Toelichting:** Wil je bij registratie een team of een rol toekennen, dan doe je dat ook in CreateNewUser. Dat is gewone PHP, dus je kan er een action uit je eigen project aanroepen. Zie ook de les over architectuur voor waar zulke logica hoort.

<!-- slide 29 -->

### 2FA en passkeys

- 2FA met een authenticator-app (TOTP) zit standaard in de starter kits. Je User gebruikt daarvoor de trait `TwoFactorAuthenticatable`.
- Recovery codes vangen het op als iemand zijn gsm kwijt is. Laat de gebruiker ze bewaren bij het aanzetten.
- Passkeys werken via WebAuthn, zoals Face ID of Windows Hello. In Fortify zet je ze aan met `Features::passkeys()`.
- Een passkey is niet te phishen: hij werkt alleen op het domein waarvoor hij gemaakt is, dus een valse loginpagina krijgt niets.
- Voor klanten met gevoelige data, zoals HR of medische gegevens, is 2FA verplicht maken een goed voorstel.

> **Toelichting:** Passkeys zijn nieuw in Fortify en werken alleen over https, of op localhost. Op een .test-domein van Herd zet je https aan met herd secure. Bij 2FA moet je User de kolommen two_factor_secret en two_factor_recovery_codes hebben, die migratie komt mee met Fortify.

<!-- slide 30 -->

## 4. Verificatie en wachtwoorden

> **Toelichting:** Verificatie en reset lijken bijzaak tot een klant een e-mail krijgt die niet voor hem bedoeld was. Beide flows werken met een link in een mail, en de veiligheid hangt af van hoe die link gemaakt en gecheckt wordt.

<!-- slide 31 -->

### E-mailverificatie

Zonder verificatie maakt iemand een account op het adres van een ander. Stel: facturen gaan naar dat adres. Dan lekt je portaal data naar iemand die het adres niet eens bezit.

- *app/Models/User.php*

  ```php
  class User extends Authenticatable
      implements MustVerifyEmail
  {
      // ...
  }
  ```
- *routes/web.php*

  ```php
  Route::middleware(['auth', 'verified'])
      ->group(function () {
          // ...
      });
  ```

Na het `Registered` event stuurt Laravel zelf een mail. `verified` stuurt wie nog niet bevestigd heeft naar de route `verification.notice`.

> **Toelichting:** Test het lokaal met de log-maildriver of met de mail catcher van Herd, zodat je de link kan openen zonder echte mail. De les over mail gaat daar verder op in. Wil je een eigen tekst in de mail, gebruik dan VerifyEmail::toMailUsing() in AppServiceProvider.

<!-- slide 32 -->

### De drie routes

| Route | Wat hij doet |
| --- | --- |
| `verification.notice` | De pagina "check je mailbox" |
| `verification.verify` | De link uit de mail, achter `auth` en `signed`. Vult `email_verified_at`. |
| `verification.send` | Opnieuw sturen, achter `throttle:6,1`: zes keer per minuut |

De link is een signed url: er zit een handtekening in de query string. Past iemand het id of de hash aan, dan klopt de handtekening niet meer. Registreer je gebruikers zelf, zonder starter kit, dispatch dan `event(new Registered($user))`, anders vertrekt er geen mail. Zie de [docs over email verification](https://laravel.com/docs/13.x/verification).

> **Toelichting:** Een signed url maak je ook zelf, met URL::signedRoute() of URL::temporarySignedRoute() met een vervaldatum. Handig voor een uitschrijflink of een downloadlink zonder login. De route krijgt dan de signed middleware.

<!-- slide 33 -->

### Wachtwoord vergeten

1. **Aanvraag**: POST `/forgot-password` met een e-mailadres, `Password::sendResetLink` maakt een token
2. **Mail**: Een link naar `/reset-password/{token}`. In `password_reset_tokens` staat alleen de hash.
3. **Formulier**: Token, e-mail en het nieuwe wachtwoord gaan terug naar de server
4. **Reset**: `Password::reset` checkt token en leeftijd, standaard 60 minuten, en roept jouw closure aan

Het token in de mail is het enige bewijs dat jij de eigenaar van het adres bent. Daarom bewaart Laravel alleen een hash ervan, net als bij een wachtwoord: wie de database leest, kan er niets mee. Fortify en de starter kits hebben deze routes al. Zie de [docs over resetting passwords](https://laravel.com/docs/13.x/passwords).

> **Toelichting:** Het token staat gehasht in de tabel, dus zelfs iemand met toegang tot de database kan geen reset doen. De throttle-instelling in config/auth.php zorgt dat iemand niet elke seconde een nieuwe mail kan aanvragen voor hetzelfde adres.

<!-- slide 34 -->

### Password::reset

```php
$status = Password::reset(
    $request->only('email', 'password', 'password_confirmation', 'token'),
    function (User $user, string $password) {
        $user->forceFill([
            'password' => Hash::make($password),
        ])->setRememberToken(Str::random(60));

        $user->save();
        event(new PasswordReset($user));
    },
);
```

Laravel checkt eerst of het token bij dit adres hoort en nog geen 60 minuten oud is. Pas dan draait jouw closure: het nieuwe wachtwoord wordt gehasht, en er komt een nieuwe `remember_token`. Daardoor werkt geen enkele oude onthoud-mij-cookie nog.

> **Toelichting:** forceFill gebruik je omdat password meestal niet in $fillable staat, en dat is goed zo. Het PasswordReset event kan je gebruiken om een mail te sturen: je wachtwoord is gewijzigd, was jij dit niet, contacteer ons. Dat is een goede extra voor elk project.

<!-- slide 35 -->

### Het resultaat tonen

```php
return $status === Password::PasswordReset
    ? redirect()->route('login')->with('status', __($status))
    : back()->withErrors(['email' => [__($status)]]);
```

`$status` is een sleutel zoals `passwords.reset` of `passwords.token`. Met `__()` wordt dat een zin uit `lang/nl/passwords.php`. Die map maak je met `php artisan lang:publish`, en dan vertaal je de boodschappen zelf.

> **Toelichting:** Password::PasswordReset en Password::ResetLinkSent zijn constanten. In oudere code zie je Password::PASSWORD_RESET in hoofdletters, dat is dezelfde waarde. Laravel heeft geen Nederlandse vertalingen ingebouwd: het package laravel-lang/lang heeft ze wel.

<!-- slide 36 -->

### Verklap niet wie er klant is

Een melding "dit adres kennen we niet" maakt van je formulier een lijst van klanten. Iedereen kan er adressen in plakken en zien welke een account hebben. Dat heet user enumeration.

> **Check het in je eigen project**
>
> Vul bij wachtwoord vergeten een adres in dat niet bestaat. Zegt je starter kit dat het adres onbekend is, maak de melding dan dezelfde als voor een bestaand adres, bijvoorbeeld "Als dit adres bestaat, is er een mail onderweg." Dat pas je aan in `lang/nl/passwords.php` of in de controller.

> **Toelichting:** Hetzelfde geldt voor registratie: een fout zoals dit adres is al in gebruik verklapt ook dat een account bestaat. Daar is het moeilijker te vermijden, want de gebruiker moet weten waarom het niet lukt. Rate limiting op die routes maakt massaal testen in elk geval traag.

<!-- slide 37 -->

### De link in de mail

- Laravel bouwt de link met de Host header van de request. Wie die header vervalst, krijgt een mail met een link naar zijn eigen domein. Zet daarom `$middleware->trustHosts(at: ['klantportaal.be'])` in `bootstrap/app.php`.
- Verlopen tokens blijven in de tabel staan. `auth:clear-resets` in de scheduler ruimt ze op.
- Een reset-mail is een transactionele mail. Stuur hem niet via dezelfde stream als de nieuwsbrief, anders belandt hij sneller in spam. De les over mail gaat daar verder op in.

> **Toelichting:** Host header poisoning werkt alleen als je webserver requests voor elke hostnaam doorlaat. Op Laravel Cloud of Forge is dat meestal al afgeschermd, maar trustHosts kost niets en beschermt ook lokaal en op staging.

<!-- slide 38 -->

### Waarom zet de reset-closure ook een nieuwe remember_token?

- A. Omdat de kolom anders leeg blijft
- B. Zodat onthoud-mij-cookies op andere toestellen niet meer werken
- C. Om de mail opnieuw te kunnen sturen
- D. Anders faalt Hash::make

**Antwoord:** B. Zodat onthoud-mij-cookies op andere toestellen niet meer werken

Wie zijn wachtwoord reset, vermoedt vaak dat iemand anders binnen is. Met een nieuwe remember_token klopt geen enkele oude remember cookie nog, dus die andere persoon moet opnieuw inloggen, met een wachtwoord dat hij niet kent. De kolom bestond al en was niet leeg, en Hash::make heeft er niets mee te maken: dat hasht alleen het wachtwoord.

> **Toelichting:** Een reset betekent niet dat alle sessies stoppen: de sessie op een ander toestel leeft verder tot ze verloopt. Wil je dat ook, gebruik dan de auth.session middleware, of verwijder de rijen van die gebruiker uit de sessions-tabel.

<!-- slide 39 -->

## 5. API-authenticatie met Sanctum

> **Toelichting:** Zodra er iets anders dan een browser met je Laravel-app praat, heb je dit hoofdstuk nodig: een mobiele app, een koppeling met de boekhouding van de klant, of een aparte frontend. De les over API development bouwt hierop verder.

<!-- slide 40 -->

### Twee problemen, één package

#### API tokens

Voor anderen

Een mobiele app of een koppeling met de boekhouding van de klant. Het token gaat mee in de Authorization header, bij elke request.

#### SPA-cookies

Voor jezelf

Je eigen frontend in React of Vue, op hetzelfde topdomein als je API. Geen token, gewoon de sessie-cookie van Laravel.

> **Toelichting:** Je hoeft niet te kiezen: Sanctum doet beide in hetzelfde project. De auth:sanctum middleware probeert eerst de sessie en daarna het token. Zo werkt dezelfde API-route voor je eigen SPA en voor een app van buitenaf.

<!-- slide 41 -->

### Sanctum, Fortify of Passport

| Package | Wat het doet |
| --- | --- |
| Fortify | Registreren, inloggen, wachtwoord resetten, verificatie. De routes en controllers. |
| Sanctum | Herkent de gebruiker bij een API-request, met een token of met de sessie-cookie |
| Passport | Een volledige OAuth2-server, als andere bedrijven apps bouwen waarmee hun gebruikers op jouw platform inloggen |

Fortify en Sanctum zijn geen concurrenten: een SPA gebruikt vaak Fortify om in te loggen en Sanctum om de API te beveiligen. Passport kom je in een Agency-project zelden tegen. Zie de [docs over Sanctum](https://laravel.com/docs/13.x/sanctum).

> **Toelichting:** Passport ondersteunt de volledige OAuth2-flows, zoals de authorization code flow uit de les over SSO, maar dan met jouw app als identity provider. Heb je alleen tokens voor je eigen gebruikers nodig, dan is Sanctum veel eenvoudiger.

<!-- slide 42 -->

### Sanctum installeren

```console
$ php artisan install:api
```

*app/Models/User.php*

```php
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens;
}
```

`install:api` installeert Sanctum, maakt `routes/api.php` en de migratie voor `personal_access_tokens`. De trait geeft je User de methode `createToken` en de relatie `tokens`.

> **Toelichting:** install:api vraagt of het de migratie meteen moet draaien. Heb je al een api.php uit een ouder project, check dan of bootstrap/app.php de api-routes laadt in withRouting. Zonder de trait krijg je een fout dat createToken niet bestaat.

<!-- slide 43 -->

### Een token maken

```php
$token = $request->user()->createToken(
    name: 'Koppeling boekhouding',
    abilities: ['projects:read', 'invoices:read'],
    expiresAt: now()->addMonths(6),
);

return ['token' => $token->plainTextToken];
```

1. Een naam die de gebruiker herkent. In zijn instellingen moet hij tokens kunnen terugvinden en intrekken.
2. Wat het token mag. Laat je dit weg, dan krijgt het de ster: alles.
3. Wanneer het vervalt. Standaard vervalt een Sanctum-token nooit.
4. Het token in plain text, zoals `3|kX9f…`. De gebruiker ziet het één keer, in de database staat alleen een SHA-256 hash.

> **Toelichting:** Abilities zijn vrije strings. Een vaste vorm zoals resource:actie maakt ze leesbaar en makkelijk te checken. Bewaar ze als constanten of in een enum, dan typ je nooit projects:raed.

<!-- slide 44 -->

### Inloggen met een token

1. App → Laravel, **POST /sanctum/token**: E-mail, wachtwoord en een `device_name` die de gebruiker herkent, zoals "iPhone van Els".
2. Laravel → Database, **User + Hash::check**: Klopt het wachtwoord niet, dan een validatiefout op e-mail, zoals bij een gewone login.
3. Laravel → Database, **createToken()**: Een nieuwe rij met de naam, de abilities en de SHA-256 hash van het token.
4. Laravel → App, **3|kX9f…**: Het token in plain text, één keer. Het getal voor de streep is het id van de rij.
5. App → Laravel, **GET /api/projects**: `Authorization: Bearer 3|kX9f…` en `Accept: application/json`.
6. Laravel → Database, **Token 3 opzoeken**: Sanctum hasht de rest van het token en vergelijkt. Verlopen of ingetrokken? Dan 401.
7. Laravel → App, **200 JSON**: `$request->user()` is de eigenaar van het token. Geen sessie, geen cookie.

> **Toelichting:** Een alternatief voor de gsm-app: een token dat de gebruiker zelf aanmaakt in een instellingenscherm, zoals een personal access token op GitHub. Dat past bij koppelingen en scripts, niet bij een app waar gewone gebruikers inloggen.

<!-- slide 45 -->

### Het token-endpoint

```php
public function __invoke(IssueTokenRequest $request): string
{
    $user = User::firstWhere('email', $request->email);

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => 'Deze gegevens kloppen niet.',
        ]);
    }

    return $user->createToken($request->device_name)->plainTextToken;
}
```

Stap 1 tot 4 van het schema, in `IssueTokenController`. De Form Request valideert e-mail, wachtwoord en `device_name`.

> **Toelichting:** Het endpoint geeft het token terug als gewone string. Je kan ook JSON teruggeven, zoals ['token' => ...], dat is voor een app vaak makkelijker. Een IssueTokenRequest maak je met php artisan make:request IssueTokenRequest.

<!-- slide 46 -->

### Wat dat endpoint goed doet

- Dezelfde fout voor een onbekend adres en een fout wachtwoord, net als bij de gewone login.
- Een `ValidationException` geeft een 422 met een nette JSON-fout, zodat de app weet welk veld fout is.
- Eén token per toestel, met de naam van dat toestel. Is een gsm gestolen, dan trek je alleen dat token in.
- Het endpoint heeft geen throttling vanzelf. Hang er zelf `throttle:login` aan, zie het laatste hoofdstuk.

> **Toelichting:** Wat dit endpoint niet doet: 2FA. Heeft je project 2FA voor de web-login, dan omzeilt deze route dat. Voor een klantportaal met gevoelige data is dat een beslissing die je met de klant bespreekt.

<!-- slide 47 -->

### Abilities op de route

*bootstrap/app.php*

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->alias([
        'abilities' => CheckAbilities::class,
        'ability' => CheckForAnyAbility::class,
    ]);
})
```

*routes/api.php*

```php
Route::post('projects', [ProjectsController::class, 'store'])
    ->middleware(['auth:sanctum', 'abilities:projects:write']);
```

`abilities` eist alle abilities in de lijst, `ability` minstens één. Heeft het token ze niet, dan antwoordt Laravel 403.

> **Toelichting:** De abilities middleware staat na auth:sanctum, want hij heeft een ingelogde gebruiker met een token nodig. Zonder de alias in bootstrap/app.php krijg je een fout dat de middleware abilities niet bestaat.

<!-- slide 48 -->

### tokenCan en je policies

```php
if ($request->user()->tokenCant('invoices:read')) {
    abort(403);
}
```

Voor een request van je eigen SPA met een sessie geeft `tokenCan` altijd true. Zo kan je het overal gebruiken, zonder te weten of er een token in het spel is.

> [!WARNING]
> Een ability zegt iets over het token, niet over de gebruiker. Of Els dit project mag zien, blijft een policy. Dat is de les over autorisatie.

> **Toelichting:** Waarom tokenCan true geeft bij een SPA-request: Sanctum hangt dan een TransientToken aan de gebruiker, een token dat niet in de database staat en op elke vraag ja zegt.

<!-- slide 49 -->

### Inloggen vanuit je SPA

1. SPA → Laravel, **GET /sanctum/csrf-cookie**: Eerst CSRF-bescherming opzetten, voor de login.
2. Laravel → SPA, **XSRF-TOKEN cookie**: Axios leest die cookie en stuurt hem bij elke request terug als `X-XSRF-TOKEN` header.
3. SPA → Laravel, **POST /login**: Naar Fortify of je eigen login-route, met de web guard. E-mail, wachtwoord, CSRF-header.
4. Laravel → SPA, **Sessie-cookie**: Nieuwe sessie, HttpOnly. Voor het domein .klant.be, dus hij gaat ook mee naar api.klant.be.
5. SPA → Laravel, **GET /api/projects**: Met de cookie, dankzij `withCredentials`. De Origin is app.klant.be.
6. Laravel, **Stateful domein**: Sanctum herkent de Origin, start de sessie en laat de web guard de gebruiker vinden. Geen token in het spel.
7. Laravel → SPA, **200 JSON**: Sessie verlopen? Dan krijgt de SPA 401 of 419 en stuurt ze je naar de login.

> **Toelichting:** Stap 1 doe je één keer, voor de login. Daarna stuurt axios het CSRF-token automatisch mee zolang de cookie bestaat. Je SPA hoeft nergens een token op te slaan, en uitloggen is een POST naar /logout zoals in een gewone app.

<!-- slide 50 -->

### Geen token in localStorage

Een token in `localStorage` kan elk script op je pagina lezen. Eén lek in een npm-package of één XSS-fout, en het token is weg, met alles wat het mag. Een HttpOnly-cookie kan geen script lezen.

> [!NOTE]
> De Sanctum-docs zeggen het letterlijk: gebruik geen API tokens voor je eigen SPA. Gebruik de SPA-authenticatie.

> **Toelichting:** XSS is een lek waarbij iemand eigen JavaScript op jouw pagina krijgt, bijvoorbeeld via een naam met een script-tag die je zonder escaping toont. De les over security gaat er dieper op in. Een HttpOnly-cookie maakt zo'n lek niet onschuldig, maar het token kan in elk geval niet weg.

<!-- slide 51 -->

### De SPA configureren

- *bootstrap/app.php*

  ```php
  $middleware->statefulApi();
  ```

  Requests van een stateful domein krijgen een sessie.
- *.env*

  ```ini
  SANCTUM_STATEFUL_DOMAINS=app.klant.be
  SESSION_DOMAIN=.klant.be
  ```

  Welke domeinen stateful zijn, en een cookie voor alle subdomeinen.
- *config/cors.php*

  ```php
  'supports_credentials' => true,
  ```

  De browser mag cookies meesturen naar een ander subdomein. Publiceer het bestand met `php artisan config:publish cors`.
- *resources/js/app.js*

  ```js
  axios.defaults.withCredentials = true
  axios.defaults.withXSRFToken = true
  ```

  Axios stuurt cookies en het CSRF-token mee.

> **Toelichting:** Deze vier instellingen horen samen. Werk je op één domein, zoals alles op klantportaal.test, dan heb je de CORS-regel en SESSION_DOMAIN niet nodig. Het wordt pas lastig als de SPA en de API op verschillende subdomeinen staan.

<!-- slide 52 -->

### Als de SPA niet inlogt

| Je ziet | Kijk naar |
| --- | --- |
| 419 CSRF token mismatch | Geen call naar `/sanctum/csrf-cookie`, of `withXSRFToken` staat niet aan |
| 401 op `/api/user` na een geslaagde login | Het domein staat niet in `SANCTUM_STATEFUL_DOMAINS`, of `statefulApi()` ontbreekt |
| De cookie gaat niet mee | `withCredentials` of `supports_credentials` staat niet aan |
| Werkt op app, niet op api | `SESSION_DOMAIN` zonder punt ervoor |

Lokaal met een poort, zoals Vite op 5173? Dan moet de poort mee in de lijst: `SANCTUM_STATEFUL_DOMAINS=localhost:5173`.

> **Toelichting:** Kijk in de Network-tab naar de request die faalt. Ontbreekt de Cookie header, dan ligt het aan withCredentials of CORS. Staat de cookie er wel, maar zegt Laravel 401, dan herkent Sanctum het domein niet als stateful.

<!-- slide 53 -->

### Welke kies je

| Wie praat met je API | Wat je gebruikt |
| --- | --- |
| Je eigen React- of Vue-SPA op hetzelfde topdomein | SPA-cookies, statefulApi |
| Een mobiele app | Token per toestel, via een login-endpoint |
| De boekhouding of het ERP van de klant | Token met abilities, aangemaakt in een instellingenscherm |
| Een frontend op een ander domein | Token, of beter: de API onder hetzelfde domein zetten |
| Een Inertia-app of Livewire | De gewone web guard, zonder Sanctum |

De laatste rij is de meest gemaakte fout. Een Inertia-app is geen SPA in deze zin: het is een gewone Laravel-app met sessies, en Sanctum heb je daar niet nodig.

> **Toelichting:** Een frontend op een ander topdomein kan geen cookies van je API gebruiken, want browsers blokkeren steeds meer third party cookies. Daarom de tip om de API onder hetzelfde domein te zetten, zoals api.klant.be naast app.klant.be.

<!-- slide 54 -->

### Welke guard herkent je

Kies een request en voorspel eerst het statuscijfer. Vergelijk dan "Browser, niet ingelogd" met "Zonder token": allebei niemand ingelogd, toch een ander antwoord.

- Browser, ingelogd (auth): web guard: user id 12 in de sessie, Provider haalt User 12 op. Antwoord: 200 OK.
- Sessie verlopen, onthoud mij (auth): web guard: geen user id in de sessie, Remember cookie: id en remember_token kloppen, Opnieuw ingelogd, met een nieuwe sessie. Antwoord: 200 OK.
- Browser, niet ingelogd (auth): web guard: geen user id in de sessie, Geen remember cookie. Antwoord: 302 Redirect naar /login.
- App met token (auth:sanctum): Token 3 gevonden, de hash klopt, Niet verlopen, eigenaar is User 12. Antwoord: 200 OK.
- Zonder token (auth:sanctum): Geen Authorization header. Antwoord: 401 Unauthenticated.
- Token zonder ability (auth:sanctum, abilities:projects:write): Token 7 gevonden, eigenaar is User 12, Het token heeft alleen projects:read. Antwoord: 403 Forbidden.
- SPA op app.klant.be (auth:sanctum): Stateful domein: Sanctum start de sessie, web guard: user id 12 in de sessie. Antwoord: 200 OK.

> **Toelichting:** Onder de motorkap probeert de sanctum guard eerst de guards uit config/sanctum.php (standaard web). Voor een request van een stateful domein is er een sessie, en vindt web de gebruiker. Pas als dat niets oplevert, kijkt Sanctum naar de Authorization header.

<!-- slide 55 -->

### Tokens intrekken

```php
$user->tokens()->delete();

$request->user()->currentAccessToken()->delete();

$user->tokens()->where('id', $tokenId)->delete();
```

1. Alle tokens van een gebruiker weg, bijvoorbeeld als hij het bedrijf verlaat.
2. Alleen het token van deze request. Dat is uitloggen in een mobiele app.
3. Eén token, vanuit een instellingenscherm met een knop per token.

Bouw altijd zo'n scherm, met de naam en `last_used_at` per token. Een token van een ex-werknemer bij de klant moet weg kunnen zonder dat jij in de database moet.

> **Toelichting:** Sanctum zet last_used_at bij elke request met een token. Een token dat al maanden niet gebruikt is, is een goede kandidaat om in te trekken.

<!-- slide 56 -->

### Tokens laten vervallen

*config/sanctum.php*

```php
'expiration' => 60 * 24 * 180,
```

*routes/console.php*

```php
Schedule::command('sanctum:prune-expired --hours=24')->daily();
```

Standaard vervalt een Sanctum-token nooit. `expiration` is een aantal minuten en geldt voor elk token, `expiresAt` bij `createToken` geldt voor één token. Verlopen tokens blijven in de tabel staan tot `sanctum:prune-expired` ze opruimt.

> **Toelichting:** Kies een vervaldatum die past bij het gebruik. Een koppeling met de boekhouding mag een jaar meegaan, met een herinnering aan de klant voor ze vervalt. Een token voor een eenmalig script mag na een dag weg.

<!-- slide 57 -->

### Je React-SPA draait op app.klant.be, de Laravel-API op api.klant.be. Hoe log je in?

- A. Een token in localStorage
- B. Sanctum SPA-authenticatie met de sessie-cookie
- C. HTTP Basic auth op elke request
- D. Socialite

**Antwoord:** B. Sanctum SPA-authenticatie met de sessie-cookie

Zelfde topdomein, dus de sessie-cookie werkt, met CSRF-bescherming en HttpOnly. Een token in localStorage lijkt eenvoudiger, maar elk script op de pagina kan het lezen, ook een script dat daar niet hoort. Socialite is voor inloggen via Google of Microsoft, niet voor hoe je SPA daarna met je API praat.

> **Toelichting:** Kan de SPA niet op hetzelfde topdomein, zoals een frontend op Vercel en een API op Laravel Cloud, dan blijft een token over. Bewaar het dan zo kort mogelijk, met een korte vervaldatum, en nooit in localStorage als het kan vermeden worden.

<!-- slide 58 -->

## 6. Brute force afremmen

> **Toelichting:** Een login zonder limiet is een uitnodiging om wachtwoorden te raden. Rate limiting kost één regel code en maakt dat aanval traag genoeg om nutteloos te zijn. Hetzelfde principe beschermt ook je API tegen een script dat te snel loopt.

<!-- slide 59 -->

### Rate limiting op de login

*app/Providers/FortifyServiceProvider.php*

```php
RateLimiter::for('login', function (Request $request) {
    return Limit::perMinute(5)->by("{$request->input('email')}|{$request->ip()}");
});
```

Vijf pogingen per minuut, per combinatie van e-mailadres en IP. Alleen op IP en een heel kantoor achter één IP zit vast, terwijl een botnet gewoon van IP wisselt. Alleen op e-mail en iedereen kan het account van een ander blokkeren door vijf keer fout te typen.

De starter kits hebben dit al. Meer in de [docs over rate limiting](https://laravel.com/docs/13.x/routing#rate-limiting).

> **Toelichting:** De starter kit zet de limiter in FortifyServiceProvider, met een iets strengere sleutel: het e-mailadres in kleine letters en getransliterated, zodat Jan@ en jan@ niet als twee adressen tellen. Met Redis als cache werkt rate limiting ook over meerdere servers.

<!-- slide 60 -->

### Je eigen routes afremmen

*routes/api.php*

```php
Route::post('sanctum/token', IssueTokenController::class)
    ->middleware('throttle:login');

Route::post('invites', [InvitesController::class, 'store'])
    ->middleware('throttle:6,1');
```

`throttle:login` hergebruikt de limiter met die naam. `throttle:6,1` is de korte vorm: zes requests per minuut. Daarboven antwoordt Laravel `429 Too Many Requests`, met een `Retry-After` header die zegt hoeveel seconden je moet wachten.

> **Toelichting:** RateLimiter::for kan ook een array van limieten teruggeven, zoals 10 per minuut en 1000 per dag. Voor API-routes definieer je meestal een limiter api per gebruiker of IP. De les over API development gaat daar verder op in.

<!-- slide 61 -->

### Waarom telt de login-limiter per e-mailadres én IP?

- A. Omdat Laravel geen limiter per IP kent
- B. Zodat niemand andermans account kan blokkeren, en een kantoor achter één IP niet vastzit
- C. Om minder rijen in de cache te hebben
- D. Omdat e-mail alleen niet uniek is

**Antwoord:** B. Zodat niemand andermans account kan blokkeren, en een kantoor achter één IP niet vastzit

Alleen per e-mail kan een aanvaller elk account blokkeren door vijf keer fout te typen. Alleen per IP zit een hele school of een kantoor vast na een paar tikfouten. Laravel kan perfect per IP limiteren, met ->by($request->ip()), dat is dus niet de reden.

> **Toelichting:** Voor een echte aanval met duizenden IP's is rate limiting alleen niet genoeg. Dan helpen 2FA, passkeys en een firewall zoals die van Cloudflare. Rate limiting houdt vooral de eenvoudige scripts tegen.

<!-- slide 62 -->

### Opdracht: Een API voor het klantportaal

In een nieuw project met de Livewire starter kit. De boekhouding van de klant wil de projecten ophalen.

1. Zet e-mailverificatie aan en scherm het dashboard af met verified (slide 31)
2. Installeer Sanctum en maak een route die een token met de ability projects:read uitgeeft, achter password.confirm (slide 43)
3. Maak GET /api/projects achter auth:sanctum en abilities:projects:read (slide 47)
4. Klaar als curl met het token 200 geeft, zonder token 401, met een token zonder die ability 403, en de zesde foute login 429

Vast? De [Sanctum-docs](https://laravel.com/docs/13.x/sanctum) hebben voorbeelden voor tokens en abilities. Vergeet de header `Accept: application/json` niet in curl.

> **Toelichting:** Begin met de starter kit en log één keer in. Maak daarna eerst het token via php artisan tinker met $user->createToken('test', ['projects:read']), zodat je de API kan testen voor je het scherm bouwt. Test elke status met curl en de header Accept: application/json.

## Begrippen

- **Guard**: Bepaalt hoe Laravel bij elke request herkent wie je bent. De web guard kijkt in de sessie, de sanctum guard kijkt ook naar een token in de Authorization header. (slide 4)
- **User provider**: Bepaalt waar de guard de gebruiker ophaalt. Standaard Eloquent met App\\Models\\User, in te stellen in config/auth.php. (slide 4)
- **auth middleware**: Laat alleen ingelogde gebruikers door. Een browser gaat naar de login-route, een request die JSON verwacht krijgt 401. Met auth:admin of auth:sanctum kies je de guard. (slide 11)
- **Sessie**: Data die Laravel per bezoeker aan de serverkant bewaart, standaard in de sessions-tabel. De browser krijgt alleen een versleutelde cookie met het id. Na een login staat je user id erin. (slide 18)
- **Session fixation**: Een aanval waarbij iemand je een sessie-id opdringt en wacht tot je inlogt. Daarom roep je na een geslaagde login $request->session()->regenerate() aan: nieuw id, oude waardeloos. (slide 17)
- **Remember me**: Een tweede cookie met je user id en een willekeurige remember_token uit de users-tabel. Is je sessie verlopen, dan logt Laravel je daarmee opnieuw in. Uitloggen maakt een nieuwe token, de oude cookie werkt dan niet meer. (slide 20)
- **password.confirm**: Middleware die je wachtwoord opnieuw vraagt voor een gevoelige actie. Daarna laat Laravel je drie uur met rust, in te stellen met password_timeout. (slide 22)
- **Starter kit**: Een nieuw Laravel-project met login, registratie, wachtwoord resetten en 2FA erin, voor React, Vue, Svelte of Livewire. Alle code staat in je project, en de backend is Fortify. (slide 25)
- **Fortify**: Het headless authenticatie-package van Laravel: routes en controllers voor login, registratie, reset en verificatie, zonder UI. Welke features aan staan, zet je in config/fortify.php. (slide 26)
- **MustVerifyEmail**: Interface op je User-model. Na het Registered event stuurt Laravel dan een mail met een signed link, en de verified middleware houdt gebruikers zonder email_verified_at tegen. (slide 31)
- **Signed url**: Een url met een handtekening in de query string. Past iemand er iets aan, dan klopt de handtekening niet meer en weigert de signed middleware hem. (slide 32)
- **Password broker**: Wat achter de Password-facade zit. Maakt een reset-token, bewaart hem gehasht in password_reset_tokens, mailt de link en checkt hem bij Password::reset. Standaard 60 minuten geldig. (slide 33)
- **Sanctum**: Het package voor API-authenticatie. Lost twee dingen op: API tokens voor apps en integraties, en cookie-authenticatie voor je eigen SPA. Installeer je met php artisan install:api. (slide 41)
- **Personal access token**: Een API token van Sanctum, zoals 3|kX9…. Het getal voor de streep is het id, de rest bewaart Laravel als SHA-256 hash. De gebruiker ziet de plain text één keer. (slide 43)
- **Bearer token**: Een token dat je meestuurt in de header Authorization: Bearer <token>. Wie het heeft, is de gebruiker. Bewaar het dus zoals een wachtwoord. (slide 44)
- **Token ability**: Wat een token mag, zoals projects:read. Je geeft ze mee bij createToken en checkt ze met tokenCan of de abilities middleware. Een token zonder de juiste ability krijgt 403. (slide 47)
- **SPA-authenticatie**: Sanctum voor je eigen frontend op hetzelfde topdomein: geen token, gewoon de sessie-cookie van Laravel, met CSRF-bescherming via /sanctum/csrf-cookie. (slide 49)
- **Stateful domain**: Een domein in SANCTUM_STATEFUL_DOMAINS. Requests van daar krijgen een sessie en worden met cookies geauthenticeerd. Alle andere moeten een token meesturen. (slide 51)
- **CSRF-token**: Een token dat Laravel bij elke POST, PUT of DELETE met een sessie verwacht. In een SPA komt het uit de XSRF-TOKEN cookie en stuurt axios het terug als X-XSRF-TOKEN header. (slide 49)
- **Rate limiting**: Een maximum aantal requests per tijdseenheid, per gebruiker, IP of e-mailadres. Daarboven antwoordt Laravel 429 Too Many Requests. Op een login: vijf pogingen per minuut per e-mail en IP. (slide 59)
- **401 Unauthorized**: Het antwoord op een API-request zonder geldige login of token. Ondanks de naam gaat het over authenticatie: Laravel weet niet wie je bent. (slide 54)

## Links en bronnen

### In de presentatie

- [Authentication](https://laravel.com/docs/13.x/authentication) (slide 7): Guards, providers, attempt, logout, remember me, password.confirm
- [Session fixation](https://owasp.org/www-community/attacks/Session_fixation) (slide 17): Waarom je de sessie na een login vernieuwt
- [Starter kits](https://laravel.com/docs/13.x/starter-kits) (slide 25): React, Vue, Svelte en Livewire, allemaal op Fortify, en de WorkOS-variant
- [Laravel Fortify](https://laravel.com/docs/13.x/fortify) (slide 26): Features, authenticateUsing, login throttling, 2FA en passkeys
- [Email verification](https://laravel.com/docs/13.x/verification) (slide 32): MustVerifyEmail, de drie routes en de verified middleware
- [Resetting passwords](https://laravel.com/docs/13.x/passwords) (slide 33): Password::sendResetLink, Password::reset en trustHosts
- [Laravel Sanctum](https://laravel.com/docs/13.x/sanctum) (slide 41, slide 62): API tokens met abilities, SPA-authenticatie, statefulApi, revoke en expiration
- [Rate limiting](https://laravel.com/docs/13.x/routing#rate-limiting) (slide 59): RateLimiter::for, Limit::perMinute()->by() en de throttle middleware
