Symfony 7.1: Anleitung für ein Login-System (Backend & API mit JWT)
In dieser Anleitung erstellen wir ein Symfony 7.1 Projekt (PHP 8.2) mit einem Benutzer-Login-System für zwei Bereiche: ein klassisches Backend mit Login-Formular für Admin-Benutzer und eine API (für ein Vue3-Frontend) mit JWT-Authentifizierung für normale Benutzer. Wir nutzen MySQL als Datenbank, Doctrine als ORM, einen gemeinsamen User-Provider (die User-Entity) für beide Logins, sowie Login-Rate-Limiting und CORS-Beschränkung für die API.
Projektsetup und benötigte Komponenten
Beginnen Sie mit der Erstellung eines neuen Symfony-Projekts und der Installation der erforderlichen Pakete:
# Neues Symfony 7 Projekt (Skeleton) erstellen
composer create-project symfony/skeleton:"7.1.*" symfony-jwt-login
cd symfony-jwt-login
# Benötigte Packages installieren
composer require symfony/orm-pack # Doctrine (MySQL) und ORM
composer require symfony/security-bundle # Security (Authentifizierung)
composer require symfony/twig-bundle # Twig für Templates (Backend Login)
composer require symfony/maker-bundle --dev # Maker Bundle für Code-Generierung
composer require lexik/jwt-authentication-bundle # JWT Authentifizierung für API
composer require nelmio/cors-bundle # CORS-Konfiguration für API
composer require symfony/rate-limiter # Rate Limiter für Login-Versuche
Datenbank einrichten: In der .env Datei die MySQL-Verbindungsdetails anpassen (DATABASE_URL="mysql://user:pass@127.0.0.1:3306/db_name"). Anschließend die Datenbank erstellen:
php bin/console doctrine:database:create
User-Entity als gemeinsamer User-Provider
Für die Benutzerverwaltung erstellen wir eine User-Entity, die als gemeinsamer User-Provider für sowohl das Backend als auch die API dient. Verwenden Sie den Maker-Befehl, um die User-Entität anzulegen:
php bin/console make:user

Wählen Sie z.B. Email als eindeutigen Identifikator. Der Maker generiert die Klasse App\Entity\User mit Feldern für ID, E-Mail, Passwort und Rollen. Stellen Sie sicher, dass die Klasse folgende Aspekte abdeckt:
- Mapping: Die Klasse ist mit Doctrine-Annotationen/Attributen versehen (
#[ORM\Entity], Felder mit#[ORM\Column]etc.). Die E-Mail soll unique sein. - Security Interfaces: Implementiert
UserInterface(bzw. seit Symfony 5.3/6UserInterface+PasswordAuthenticatedUserInterfacefür Passwort-Authentifizierung). - Password Hashing: Enthält ein Feld für das gehashte Passwort und gegebenenfalls Methoden zum Setzen/Hashen (der Maker sorgt dafür, dass wir später den Passwort-Hasher verwenden).
- Rollen: Enthält ein Feld
rolesvom Typ JSON/Array. Jeder User hat mindestens die RolleROLE_USERstandardmäßig.
Zum Glück liegt der maker-Befehl die wichtigsten Dateien selbst an. Eine beispielhafte User-Entity (gekürzt) könnte so aussehen:
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
#[ORM\Entity(repositoryClass: App\Repository\UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email = null;
#[ORM\Column(type: 'json')]
private array $roles = [];
/**
* Gehashedes Passwort.
*/
#[ORM\Column(type: 'string')]
private ?string $password = null;
public function getId(): ?int { return $this->id; }
public function getEmail(): ?string { return $this->email; }
public function setEmail(string $email): self { $this->email = $email; return $this; }
public function getRoles(): array
{
$roles = $this->roles;
// Mindestens ROLE_USER garantieren:
if (!in_array('ROLE_USER', $roles)) {
$roles[] = 'ROLE_USER';
}
return $roles;
}
public function setRoles(array $roles): self { $this->roles = $roles; return $this; }
public function getUserIdentifier(): string
{
// Eindeutiger Login-Name (hier die E-Mail-Adresse)
return $this->email;
}
public function getPassword(): ?string { return $this->password; }
public function setPassword(string $password): self { $this->password = $password; return $this; }
public function eraseCredentials() { /* ... */ }
}
Hinweis:
getUserIdentifier()ersetzt die früheregetUsername()-Methode und gibt den eindeutigen Login des Users zurück (hier die E-Mail-Adresse) (Quelle). Die Implementierung vonPasswordAuthenticatedUserInterfacesignalisiert Symfony, dass diese Entity ein Passwort für Authentifizierung besitzt (Quelle).
Führen Sie nach Erstellung der Entity eine Migration durch, um die User-Tabelle anzulegen:
php bin/console make:migration
php bin/console doctrine:migrations:migrate

Security-Konfiguration (security.yaml)
Als Nächstes konfigurieren wir die Security-Einstellungen in config/packages/security.yaml, um zwei Firewalls einzurichten: eine für das Backend (mit Formular-Login, Session) und eine für die API (stateless, JWT). Zudem definieren wir den User-Provider und die Zugriffsregeln (Rollen):
# config/packages/security.yaml
security:
password_hashers:
App\Entity\User: 'auto' # Automatische Wahl des besten Hash-Algorithmus (z.B. bcrypt/argon2)
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email # Login erfolgt über die E-Mail
firewalls:
# Firewall für API-Login (nur Login-Route, ohne Authentifizierungspflicht)
login:
pattern: ^/api/login
stateless: true
anonymous: true # ermöglicht anonymen Zugriff (für Login-Anfrage)
# Firewall für API geschützte Endpunkte (JWT Auth)
api:
pattern: ^/api
stateless: true
provider: app_user_provider
# JWT Authentifizierung durch LexikJWT (registriert "jwt" Authenticator)
jwt: ~
# Firewall für das Backend (Admin-Bereich mit Session + Form Login)
main:
pattern: ^/
provider: app_user_provider
lazy: true # "anonymous: lazy" um Session nur bei Bedarf zu starten
# Form Login Konfiguration
form_login:
login_path: app_login # Route-Name des Login-Forms (s.u.)
check_path: app_login # gleiche Route verarbeitet POST (Symfony Security übernimmt)
username_parameter: email # Feldname für Benutzer (E-Mail)
password_parameter: password # Feldname für Passwort
enable_csrf: true
logout:
path: app_logout # Route-Name zum Ausloggen (wird in Controller definiert)
target: /login # Wohin nach dem Logout (Login-Seite)
access_control:
# Backend: /admin/* nur für ROLE_ADMIN
- { path: ^/admin, roles: ROLE_ADMIN }
# API-Login darf anonym aufgerufen werden (PUBLIC_ACCESS gewährt Zugriff ohne Login)
- { path: ^/api/login, roles: PUBLIC_ACCESS }
# API-Endpunkte (/api/*) erfordern gültigen Login mit Rolle ROLE_USER
- { path: ^/api, roles: ROLE_USER }
# (Weitere Regeln falls nötig ...)
Erläuterungen zur Security-Konfiguration:
- Password Hasher: Wir verwenden
'auto', sodass Symfony einen sicheren Passwort-Hasher (z.B. Argon2id) nutzt. - User-Provider: Verweist auf die
User-Entity; die Property istemail(d.h. wir suchen Benutzer anhand der E-Mail-Adresse). - Firewalls:
login: Fängt die Route/api/loginab.anonymous: trueundstateless: trueerlauben hier ungesicherten Zugriff, damit sich Benutzer über die API einloggen können (diese Firewall erzwingt keine Authentifizierung, sie dient nur dazu, die/api/loginRoute nicht von der API-Firewall blockieren zu lassen).api: Schützt alle anderen/api/**Routen.stateless: trueverzichtet auf Sessions – die Authentifizierung erfolgt bei jeder Anfrage via JWT. Durchjwt: ~wird der von LexikJWT bereitgestellte Authenticator aktiviert, der den Authorization-Header auf ein gültiges JWT überprüft.main: Deckt alle übrigen Routen (Pattern^/) ab, also unser Web-Backend. Hier iststateless: false, d.h. es werden Sessions verwendet. Konfiguriert ist einform_loginMechanismus:login_pathundcheck_pathsind beide auf die Route unseres Login-Controllers (app_login) gesetzt. Symfony zeigt bei GET diese Seite an und fängt POST-Submissions an derselben URL ab.username_parameterundpassword_parameterdefinieren die Feldnamen im Login-Formular (wir nutzen „email“ und „password“ anstelle der Standardnamen).- CSRF-Schutz ist aktiviert. Logout wird ebenfalls über eine Route (
app_logout) abgewickelt.
- Zugriffsregeln (access_control):
- Die Admin-URLs unter
/adminerfordernROLE_ADMIN. Versucht ein nicht angemeldeter Benutzer diese zu öffnen, leitet Symfony automatisch auf das Login-Formular um. Ein eingeloggter Benutzer ohne ausreichende Rolle erhält eine Fehlerseite (403 Forbidden). - Die API-Login-Route erlauben wir explizit allen (PUBLIC_ACCESS), damit man sich ohne Token dort anmelden kann.
- Alle übrigen
/api/**-Routen verlangenROLE_USER(d.h. einen gültig authentifizierten User mit dieser Rolle). Nur Benutzer mit RolleROLE_USERdürfen also die geschützte API nutzen. Dadurch wird z.B. ein Admin-Benutzer (der nurROLE_ADMINhat) selbst mit gültigem Token von API-Routen ausgeschlossen, wie gefordert.
- Die Admin-URLs unter
Login-Throttling: Limitierung von Login-Versuchen
Um Brute-Force-Angriffe zu verhindern, begrenzen wir die Anzahl an Fehlversuchen pro Zeitraum. Symfony bietet ab Version 5.2 das Feature Login-Throttling out of the box (New in Symfony 5.2: Login Throttling (Symfony Blog)). Im Falle zu vieler Fehlversuche wird der Benutzer vorübergehend gesperrt bzw. weitere Versuche blockiert („login throttling“ verhindert weitere Login-Versuche nach einer bestimmten Anzahl Fehlversuche (New in Symfony 5.2: Login Throttling (Symfony Blog))).
Für das Backend-Login können wir diese Funktion direkt in security.yaml einschalten. Fügen Sie innerhalb der main-Firewall folgendes hinzu:
security:
firewalls:
main:
# ... (andere Einstellungen wie form_login)
login_throttling:
max_attempts: 5 # z.B. 5 Fehlversuche
interval: '15 minutes' # innerhalb von 15 Minuten
Damit erlaubt Symfony z.B. 5 Login-Versuche pro 15 Minuten für das Backend-Formular. Bei Überschreitung wird weitere Authentifizierung kurzfristig blockiert (Standard: 5 Versuche/Minute, wenn nicht anders angegeben (New in Symfony 5.2: Login Throttling (Symfony Blog))).
Für die API-Logins (JSON-Login) lässt sich kein automatisches login_throttling im selben Maße nutzen, da wir den Login hier manuell implementieren. Stattdessen verwenden wir den RateLimiter direkt. Dazu definieren wir in config/packages/rate_limiter.yaml (oder im Framework-Abschnitt) einen benannten Limiter, z.B. login_attempts:
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
login_attempts:
policy: fixed_window
limit: 5
interval: '15 minutes'
Dies konfiguriert einen Rate Limiter namens login_attempts, der maximal 5 Aktionen pro 15 Minuten zulässt (danach schlägt die 6. Aktion fehl, bis die Zeit verstrichen ist). Symfony nutzt intern ähnliche Einstellungen für das login_throttling-Feature (Limit login attempts in Symfony 5+ | by Oleksii Marakhin | Medium) (New in Symfony 5.2: Login Throttling (Symfony Blog)).
Technischer Hinweis: Das RateLimiter-Feature benötigt keinen manuellen Reset – nach Ablauf des Intervalls werden neue Versuche wieder zugelassen. Alternativ könnte man auch eine gleitende Zeitfenster- oder Token-Bucket-Strategie wählen; hier verwenden wir ein Festfenster (fixed window) zur Einfachheit.
Backend: Login-Formular für Admin-Benutzer
Zunächst implementieren wir das Login-Formular für das Symfony-Backend, das Administratoren (ROLE_ADMIN) vorbehalten ist. Dieses besteht aus einem Twig-Template und einem Security-Controller.
SecurityController für das Backend
Erstellen Sie einen Controller (z.B. src/Controller/SecurityController.php), der die Login-Seite anzeigt und den Logout abwickelt. Symfony’s form_login Mechanismus übernimmt die eigentliche Authentifizierung beim Abschicken des Formulars, daher müssen wir das Login-Formular nur rendern. Wir nutzen den AuthenticationUtils, um vorherige Eingaben/Fehler bereitzustellen:
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authUtils): Response
{
// Wenn bereits eingeloggt, z.B. direkt auf /admin weiterleiten
if ($this->getUser()) {
return $this->redirectToRoute('admin_dashboard'); // (Route zu Admin-Bereich)
}
// letzten Benutzernamen und Fehler aus dem AuthUtils holen
$lastUsername = $authUtils->getLastUsername();
$error = $authUtils->getLastAuthenticationError();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
// Kein Code nötig – Symfony fängt diese Route ab und erledigt den Logout.
}
}
- Die Route
/login(name: app_login) dient als Login-Seite (GET) und als Form Action (POST) dank der Konfigurationcheck_path = login_path. - Vor dem Rendern prüfen wir mit
$this->getUser(), ob bereits jemand eingeloggt ist, um ggf. direkt weiterzuleiten (optional). AuthenticationUtilsliefert:getLastAuthenticationError(): den Fehler der letzten Login-Anmeldung (z.B. „Ungültige Anmeldedaten“), falls vorhanden.getLastUsername(): den zuletzt eingegebenen Benutzernamen (hier E-Mail), um ihn im Formular wieder anzuzeigen.
Die Logout-Route /logout muss existieren, bleibt aber leer – beim Aufruf wird der Security-Mechanismus automatisch die Session zerstören.
Twig-Template für das Login-Formular
Legen Sie unter templates/security/login.html.twig ein Template an, das das Formular anzeigt. Ein einfaches Beispiel mit Bootstrap könnte so aussehen:
{% extends 'base.html.twig' %}
{% block title %}Admin Login{% endblock %}
{% block body %}
<h2>Login</h2>
{% if error %}
<div class="alert alert-danger">
{{ error.messageKey|trans(error.messageData, 'security') }}
</div>
{% endif %}
<form method="post" action="{{ path('app_login') }}">
<div class="mb-3">
<label for="email">E-Mail</label>
<input type="text" name="email" id="email" value="{{ last_username }}" required autofocus class="form-control"/>
</div>
<div class="mb-3">
<label for="password">Passwort</label>
<input type="password" name="password" id="password" required class="form-control"/>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button class="btn btn-primary" type="submit">Login</button>
</form>
{% endblock %}
Wichtige Details zum Formular:
- Das
actionverweist auf die Login-Route (app_login), entsprechend unserer Security-Config. - Die
name-Attribute der Felder müssen mitusername_parameter/password_parameterübereinstimmen (hier „email“ und „password“). - Der CSRF-Token wird mit
csrf_token('authenticate')generiert und als Hidden-Field_csrf_tokenübergeben. Dies muss bei aktiviertem CSRF-Schutz vorhanden sein, damit der Login akzeptiert wird. - Wenn
errorgesetzt ist, wird eine Fehlermeldung angezeigt. Symfony liefert typische Fehlertexte als Übersetzungsschlüssel (z.B.Invalid credentials.), die wir über dentrans-Filter aus dem Katalogsecurityübersetzen lassen.
Jetzt ist das Backend-Login fertig. Ein Benutzer mit ROLE_ADMIN kann sich über /login anmelden. Nach erfolgreichem Login wird er standardmäßig auf die zuvor geschützte Seite (z.B. /admin) geleitet. Stellen Sie sicher, dass es z.B. eine Route /admin (oder ein Dashboard) gibt, die mit ROLE_ADMIN geschützt ist – entweder per access_control (wie oben) oder via Controller-Attribute. Zum Beispiel:
#[Route('/admin', name: 'admin_dashboard')]
#[IsGranted('ROLE_ADMIN')]
public function adminDashboard(): Response {
// ...
}
Damit sind die Admin-Login-Funktion und der geschützte Admin-Bereich umgesetzt.
API: JWT-Authentifizierung für das Vue3-Frontend
Für das Frontend (z.B. eine Vue3-App) richten wir einen JSON-Login via API ein, der ein JWT zurückgibt, sowie den Schutz aller API-Routen mittels JWT. Außerdem begrenzen wir die Login-Versuche und konfigurieren CORS, damit nur definierte Origins (die Frontend-Domain) Zugriff erhalten.
JWT einrichten (LexikJWTAuthenticationBundle)
Das LexikJWTAuthenticationBundle übernimmt viel der JWT-Integration für uns:
- Schlüssel erzeugen: JSON Web Tokens werden mit einem privaten Schlüssel signiert. Nutzen Sie den mitgelieferten Befehl, um ein Schlüsselpaar zu erzeugen:
php bin/console lexik:jwt:generate-keypairDieser Befehl erstellt standardmäßig zwei Dateien:config/jwt/private.pem(Privat-Schlüssel) undconfig/jwt/public.pem(Public-Key) (LexikJWTAuthenticationBundle Documentation). Optional kann ein Passphrase gesetzt werden. - Konfiguration Schlüssel & Passphrase: In der
.envfügen wir Variablen hinzu, die auf die Schlüssel verweisen, z.B.:###< lexik/jwt-authentication-bundle ### JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PASSPHRASE=YourPassphraseFallsVorhanden ###> lexik/jwt-authentication-bundle ###Inconfig/packages/lexik_jwt_authentication.yamlbinden wir diese ein:lexik_jwt_authentication: secret_key: '%env(resolve:JWT_SECRET_KEY)%' public_key: '%env(resolve:JWT_PUBLIC_KEY)%' pass_phrase: '%env(JWT_PASSPHRASE)%' token_ttl: 3600 # Gültigkeit in Sekunden (hier 1 Stunde)Diese Einstellungen teilen dem Bundle mit, wo die Keys liegen und wie lange ein Token gültig bleibt (LexikJWTAuthenticationBundle Documentation). - Security-Firewall (JWT): Die
security.yamlhaben wir bereits angepasst: durchfirewalls.api.jwt: ~übernimmt das Lexik-Bundle die Validierung der JWTs imAuthorizationHeader. Außerdem haben wir inaccess_controlfestgelegt, dass alle Requests unter/apiauthentifiziert sein müssen (ROLE_USER). Ein nicht authentifizierter Zugriff auf geschützte Endpunkte führt zu HTTP 401 (Unauthorized). Hinweis: Stellen Sie sicher, dass die Reihenfolge der Firewalls so ist, dass dielogin-Firewall vor derapi-Firewall steht (LexikJWTAuthenticationBundle Documentation). In unserer Config haben wirloginzuerst definiert. So wird/api/loginvom richtigen Firewall behandelt und nicht fälschlicherweise vom allgemeinen API-Firewall (LexikJWTAuthenticationBundle Documentation).
API-Login-Controller mit JWT-Ausgabe
Nun implementieren wir den API-Login-Endpoint selbst. Dieser erhält E-Mail und Passwort (per JSON), prüft die Anmeldedaten und gibt bei Erfolg ein JWT zurück. Zusätzlich berücksichtigen wir die Login-Limitierung.
Erstellen Sie z.B. src/Controller/Api/AuthController.php:
<?php
namespace App\Controller\Api;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
class AuthController extends AbstractController
{
#[Route('/api/login', name: 'api_login', methods: ['POST'])]
public function login(
Request $request,
UserRepository $userRepository,
UserPasswordHasherInterface $passwordHasher,
JWTTokenManagerInterface $JWTTokenManager,
RateLimiterFactory $loginAttemptsLimiter
): JsonResponse {
$data = json_decode($request->getContent(), true);
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';
// Rate Limiting: Limit Login-Versuche pro Benutzer/Email
$limiter = $loginAttemptsLimiter->create($email);
if (false === $limiter->consume(1)->isAccepted()) {
// Zu viele fehlgeschlagene Versuche
$retryAfter = $limiter->getRetryAfter()->getTimestamp(); // Zeitpunkt, wann wieder erlaubt
return $this->json([
'message' => 'Too many login attempts, please try later.'
], 429); // 429 Too Many Requests
}
// Benutzer nach E-Mail finden
/** @var User $user */
$user = $userRepository->findOneBy(['email' => $email]);
if (!$user) {
// Absichtlich gleiche Fehlermeldung wie bei falschem Passwort, um Userenumation zu erschweren
throw new AuthenticationException('Invalid credentials.');
}
// Passwort prüfen
if (!$passwordHasher->isPasswordValid($user, $password)) {
throw new AuthenticationException('Invalid credentials.');
}
// Rollen prüfen: nur ROLE_USER darf API-Login
if (!in_array('ROLE_USER', $user->getRoles()) || in_array('ROLE_ADMIN', $user->getRoles())) {
// Wenn Benutzer keine ROLE_USER hat oder ein Admin ist, verweigern
throw new AuthenticationException('Access denied for this user.');
}
// Wenn wir hier sind: Auth erfolgreich -> JWT erzeugen
$token = $JWTTokenManager->create($user);
return $this->json(['token' => $token]);
}
}
Erläuterungen zum API-Login-Controller:
- Die Route
/api/loginnimmt nur POST-Anfragen entgegen. Der Request-Body sollte JSON mitemailundpasswordenthalten. - RateLimiter: Wir verwenden den injizierten
RateLimiterFactory $loginAttemptsLimiter(Name via Autowiring an den konfigurierten Limiter gebunden). Mit$limiter = $loginAttemptsLimiter->create($email)erstellen wir einen „Token-Bucket“ für die spezifische User-Identifier (hier die E-Mail-Adresse als Schlüssel).consume(1)verbraucht einen Versuch; istisAccepted()= false, wurde das Limit überschritten – wir senden einen Fehlerstatus 429 Too Many Requests zurück. (Optional kann man dem Frontend auchRetry-AfterHeader mitgeben, hier zur Einfachheit nur eine Meldung.) - Benutzer- und Passwortprüfung: Wir holen den User aus der DB. Falls nicht gefunden oder das Passwort nicht stimmt, werfen wir eine
AuthenticationException. (Symfony fängt diese im JSON-Context nicht automatisch ab, daher könnte man auch stattdessen eine JsonResponse mit einem generischen Fehler zurückgeben. Wichtig ist, keine zu detaillierte Info zu geben, um nicht preiszugeben, ob die E-Mail existiert.) - Rollen-Einschränkung: Mit der If-Bedingung stellen wir sicher, dass nur User mit
ROLE_USERund ohneROLE_ADMINsich hier anmelden können. Falls der gefundene User nicht die nötige Rolle hat (oder ein Admin ist), brechen wir ab mit Access Denied. Damit erfüllen wir die Vorgabe „Nur Benutzer mit ROLE_USER dürfen sich hier anmelden“. - JWT generieren: Der
JWTTokenManagerInterface(vom Lexik-Bundle) erstellt via$JWTTokenManager->create($user)ein JWT für den Benutzer. Dieses Token enthält per Default den Usernamen (E-Mail) und Rollen im Payload. Wir schicken es als JSON an den Client (`{‚token‘: ‚eyJhbGciOi…‘}‘).
Nach erfolgreichem Login kann das Vue3-Frontend dieses Token speichern (z.B. im LocalStorage) und für Folgeanfragen im Authorization: Bearer <token> Header mitsenden. Die Symfony-Security prüft dann automatisch die Gültigkeit und setzt den User entsprechend (dank unserer JWT-Firewall-Konfiguration).
Tipp: In einer realen Anwendung sollten bei falschen Logins entsprechende HTTP-Fehlercodes und Antworten zurückgegeben werden (z.B. 401 Unauthorized mit einer Meldung „Invalid credentials“). Im obigen Beispiel wird zur Vereinfachung eine Exception geworfen. Man könnte dies abfangen oder einen
JsonResponsemit 401 zurückgeben. Ebenso könnte man bei jedem Fehlversuch den RateLimiter nur dann hochzählen; im Beispiel konsumieren wir direkt bei jedem Versuch (auch erfolgreichen) einen Token. Man könnte bei erfolgreichem Login den Zähler ggf. zurücksetzen ($limiter->reset()), was hier aber vernachlässigt werden kann, da nach Erfolg selten unmittelbar weitere Fehlversuche folgen.
CORS-Konfiguration (NelmioCorsBundle)
Um sicherzustellen, dass nur unsere Frontend-Origin (Domain) API-Anfragen stellen darf, konfigurieren wir CORS entsprechend. Nach Installation des NelmioCorsBundle gibt es meist eine Datei config/packages/nelmio_cors.yaml. Passen Sie diese an:
nelmio_cors:
defaults:
allow_credentials: false
allow_headers: ['Content-Type', 'Authorization']
allow_methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
max_age: 3600
paths:
'^/api/':
allow_origin: ['https://example-frontend.com'] # Erlaubte Origin (Frontend-Domain)
allow_headers: ['Content-Type', 'Authorization'] # Welche Header vom Client gesendet werden dürfen
allow_methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
max_age: 3600
Hier haben wir festgelegt, dass für alle Pfade unter /api/ nur Anfragen vom Origin https://example-frontend.com erlaubt sind. Passen Sie allow_origin an die URL Ihres Vue3-Frontends an (für lokale Entwicklung z.B. http://localhost:8080). Wir erlauben gängige Methoden sowie die Header Content-Type (für JSON) und Authorization (für den Bearer-Token). Preflight-Anfragen (OPTIONS) werden automatisch behandelt.
Hinweis: Mit obiger Einstellung würden alle anderen Origins vom Browser blockiert, da sie keine
Access-Control-Allow-Originerhalten. Dies erhöht die Sicherheit, indem z.B. Skripte von fremden Domains nicht einfach die API nutzen können. (Beachten Sie jedoch, dass CORS ein Client-Sicherheitsmechanismus ist – serverseitig könnten fremde Hosts nach wie vor versuchen, die API zu kontaktieren. Daher sollten die Authentifizierung und Rate Limits immer korrekt greifen.)
Die allow_credentials ist hier false, da wir kein Cookie-basiertes Auth haben – falls im Frontend Cookies oder Auth-Header mit Credentials gesendet würden, müsste man das ggf. aktivieren. In unserem JWT-Szenario nicht nötig.
Quelle: Die Konfiguration orientiert sich an den NelmioCorsBundle-Dokumenten (NelmioCorsBundle Documentation), wo gezeigt wird, wie man mittels allow_origin bestimmte Ursprungs-Domains zulässt (hier ein konkretes Beispiel mit Regex für localhost) (NelmioCorsBundle Documentation).
Zusammenfassung und weitere Hinweise
- Wir haben ein Symfony-Backend mit einem geschützten
/admin-Bereich und einem Login-Formular für Administratoren umgesetzt. Die Security-Komponente übernimmt die Formular-Authentifizierung, während wir das Frontend (Twig) bereitstellen. Ungültige Logins werden begrenzt, sodass nach X Fehlversuchen vorübergehend keine weiteren Versuche möglich sind (Brute-Force-Schutz) (New in Symfony 5.2: Login Throttling (Symfony Blog)). - Wir haben eine REST-API für das Frontend geschaffen, die über JWT Tokens gesichert ist. Ein Benutzer mit gültigen Anmeldedaten erhält ein Token und kann damit autorisierte API-Requests durchführen. Alle API-Routen sind serverseitig durch das JWT (und die ROLE_USER) geschützt – ohne gültigen Token gibt es keinen Zugriff (HTTP 401).
- Nur bestimmte Benutzerrollen können sich jeweils anmelden (Trennung von Admin und normalem User) und durch CORS ist auch der Aufruf der API auf bekannte Origins beschränkt.
- Vergessen Sie nicht, ggf. im Frontend das erhaltene JWT zu speichern und bei Folgeanfragen zu senden. Üblicherweise sendet man den Token im
AuthorizationHeader alsBearer <token>. Das LexikJWTBundle extrahiert und prüft diesen automatisch pro Request. - Testen Sie die Lösung: Legen Sie ein paar Benutzer in der Datenbank an (z.B. via Doctrine Fixtures oder
bin/console doctrine:query:sqlInserts) – einen Admin (mit RolleROLE_ADMIN) und einen normalen User (ROLE_USER). Stellen Sie sicher, dass Passwörter gehasht sind (nutzen Sie z.B. den Befehlphp bin/console security:hash-passwordum einen Hash zu erzeugen, oder implementieren Sie eine Registrierung). Prüfen Sie das Login im Browser für den Admin und via HTTP-Client (Curl/Postman) für die API.
Mit dieser umfassenden Konfiguration und Codebasis haben wir ein vollständiges Login-System für Symfony 7.1 erstellt: inklusive Doctrine-User, Form-Login (Admin), JWT-Auth (API), Rollenprüfung, Login-Rate-Limitierung und CORS-Schutz. Viel Erfolg beim Implementieren!
0 Kommentare