Artikel

Symfony 7.1: Anleitung für ein Login-System (Backend & API mit JWT)

Allgemein

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/6 UserInterface + PasswordAuthenticatedUserInterface fü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 roles vom Typ JSON/Array. Jeder User hat mindestens die Rolle ROLE_USER standardmäß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ühere getUsername()-Methode und gibt den eindeutigen Login des Users zurück (hier die E-Mail-Adresse) (Quelle). Die Implementierung von PasswordAuthenticatedUserInterface signalisiert 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 ist email (d.h. wir suchen Benutzer anhand der E-Mail-Adresse).
  • Firewalls:
    • login: Fängt die Route /api/login ab. anonymous: true und stateless: true erlauben hier ungesicherten Zugriff, damit sich Benutzer über die API einloggen können (diese Firewall erzwingt keine Authentifizierung, sie dient nur dazu, die /api/login Route nicht von der API-Firewall blockieren zu lassen).
    • api: Schützt alle anderen /api/** Routen. stateless: true verzichtet auf Sessions – die Authentifizierung erfolgt bei jeder Anfrage via JWT. Durch jwt: ~ 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 ist stateless: false, d.h. es werden Sessions verwendet. Konfiguriert ist ein form_login Mechanismus:
      • login_path und check_path sind 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_parameter und password_parameter definieren 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 /admin erfordern ROLE_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 verlangen ROLE_USER (d.h. einen gültig authentifizierten User mit dieser Rolle). Nur Benutzer mit Rolle ROLE_USER dürfen also die geschützte API nutzen. Dadurch wird z.B. ein Admin-Benutzer (der nur ROLE_ADMIN hat) selbst mit gültigem Token von API-Routen ausgeschlossen, wie gefordert.

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:

&lt;?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 Konfiguration check_path = login_path.
  • Vor dem Rendern prüfen wir mit $this->getUser(), ob bereits jemand eingeloggt ist, um ggf. direkt weiterzuleiten (optional).
  • AuthenticationUtils liefert:
    • 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 %}
&lt;h2>Login&lt;/h2>

{% if error %}
    &lt;div class="alert alert-danger">
        {{ error.messageKey|trans(error.messageData, 'security') }}
    &lt;/div>
{% endif %}

&lt;form method="post" action="{{ path('app_login') }}">
    &lt;div class="mb-3">
        &lt;label for="email">E-Mail&lt;/label>
        &lt;input type="text" name="email" id="email" value="{{ last_username }}" required autofocus class="form-control"/>
    &lt;/div>
    &lt;div class="mb-3">
        &lt;label for="password">Passwort&lt;/label>
        &lt;input type="password" name="password" id="password" required class="form-control"/>
    &lt;/div>
    &lt;input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
    &lt;button class="btn btn-primary" type="submit">Login&lt;/button>
&lt;/form>
{% endblock %}

Wichtige Details zum Formular:

  • Das action verweist auf die Login-Route (app_login), entsprechend unserer Security-Config.
  • Die name-Attribute der Felder müssen mit username_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 error gesetzt ist, wird eine Fehlermeldung angezeigt. Symfony liefert typische Fehlertexte als Übersetzungsschlüssel (z.B. Invalid credentials.), die wir über den trans-Filter aus dem Katalog security ü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:

  1. 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-keypair Dieser Befehl erstellt standardmäßig zwei Dateien: config/jwt/private.pem (Privat-Schlüssel) und config/jwt/public.pem (Public-Key) (LexikJWTAuthenticationBundle Documentation). Optional kann ein Passphrase gesetzt werden.
  2. Konfiguration Schlüssel & Passphrase: In der .env fü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 ### In config/packages/lexik_jwt_authentication.yaml binden 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).
  3. Security-Firewall (JWT): Die security.yaml haben wir bereits angepasst: durch firewalls.api.jwt: ~ übernimmt das Lexik-Bundle die Validierung der JWTs im Authorization Header. Außerdem haben wir in access_control festgelegt, dass alle Requests unter /api authentifiziert 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 die login-Firewall vor der api-Firewall steht (LexikJWTAuthenticationBundle Documentation). In unserer Config haben wir login zuerst definiert. So wird /api/login vom 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:

&lt;?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/login nimmt nur POST-Anfragen entgegen. Der Request-Body sollte JSON mit email und password enthalten.
  • 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; ist isAccepted() = false, wurde das Limit überschritten – wir senden einen Fehlerstatus 429 Too Many Requests zurück. (Optional kann man dem Frontend auch Retry-After Header 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_USER und ohne ROLE_ADMIN sich 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 JsonResponse mit 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-Origin erhalten. 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 Authorization Header als Bearer <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:sql Inserts) – einen Admin (mit Rolle ROLE_ADMIN) und einen normalen User (ROLE_USER). Stellen Sie sicher, dass Passwörter gehasht sind (nutzen Sie z.B. den Befehl php bin/console security:hash-password um 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