Blog

Sudo Mode in Symfony 8.2: Passwort noch mal abfragen, aber nur, wenn es zählt

Du kennst den Moment auf GitHub, wenn du ein Repository löschen willst und es dich stoppt: „Confirm access“. Passwort eintippen, weiter geht's. Das ist der Sudo Mode, und Symfony 8.2 bringt ihn nativ mit: zwei neue Security-Attribute, einen Zeitstempel auf dem Token und einen Entry Point, der Leute auf eine Confirm-Password-Seite schickt statt auf einen 403. Bauen wir ihn ein.

symfony php security 8 Min. Lesezeit

Von David Gorges

Geschäftsführer

Fully authenticated … seit wann?

Das Problem mit IS_AUTHENTICATED_FULLY: Es sagt dir, wie die Session begonnen hat, nicht wann. Jemand meldet sich morgens um neun mit Passwort an, lässt um fünf den Laptop entsperrt im Café stehen, und wer sich als Nächstes hinsetzt, ist immer noch „fully authenticated“. Diese Person kann die E-Mail-Adresse ändern, eine Zahlungsmethode hinzufügen oder das Konto löschen. Symfony hat damit kein Problem.

GitHub hat das schon vor Jahren mit dem „Sudo Mode“ gelöst: Bei sensiblen Aktionen fragt GitHub noch einmal nach deinem Passwort, es sei denn, du hast es vor ein paar Minuten eingegeben. Javier Eguiluz hat sich dasselbe für Symfony in Issue #33955 gewünscht. Das war im Oktober 2019. Sieben Jahre später hat Nicolas Grekas es als Stapel aus vier Pull Requests umgesetzt, und es erscheint im November mit Symfony 8.2.

Ja, sieben Jahre. Gut Ding will Weile haben. Schauen wir uns an, was wir bekommen.

Zwei Attribute, zwei Uhren

Im Kern besteht das Feature aus zwei neuen Attributen neben denen, die du schon kennst:

  • IS_AUTHENTICATED_RECENTLY: Der User hat seine Zugangsdaten in den letzten zwei Stunden eingegeben.
  • IS_AUTHENTICATED_VERY_RECENTLY: in den letzten fünf Minuten.

Beide sind strikt stärker als IS_AUTHENTICATED_FULLY, und du benutzt sie genauso:

src/Controller/AccountController.php
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class AccountController extends AbstractController
{
    #[Route('/account/email', name: 'app_account_email')]
    #[IsGranted('IS_AUTHENTICATED_RECENTLY')]
    public function changeEmail(): Response
    {
        // ...
    }

    #[Route('/account/delete', name: 'app_account_delete')]
    #[IsGranted('IS_AUTHENTICATED_VERY_RECENTLY')]
    public function deleteAccount(): Response
    {
        // ...
    }
}
Für eine neue E-Mail-Adresse braucht es einen Login aus den letzten zwei Stunden, fürs Löschen des Kontos einen aus den letzten fünf Minuten.

Die beiden Zeitfenster sind GitHubs Werte, und beide lassen sich konfigurieren, in Sekunden:

config/packages/security.yaml
security:
    recent_authentication_lifetime: 3_600       # Standard: 7_200 (2 Stunden)
    very_recent_authentication_lifetime: 60     # Standard: 300 (5 Minuten)

Die Attribute funktionieren auch in access_control, und Security-Expressions bekommen zwei passende Funktionen, is_recently_authenticated() und is_very_recently_authenticated(). Praktisch in Twig: Blende den gefährlichen Button aus, bis ein Klick darauf auch wirklich funktionieren würde.

Woher kommt der Zeitstempel?

Das ist mein Lieblingsteil, weil er so klein ist. Ein neuer Listener, AuthenticationTimeListener, hört auf das gute alte InteractiveLoginEvent und schreibt die aktuelle Zeit als Attribut namens auth_time auf das Token:

symfony/security-http: EventListener/AuthenticationTimeListener.php
final class AuthenticationTimeListener implements EventSubscriberInterface
{
    public function onInteractiveLogin(InteractiveLoginEvent $event): void
    {
        $event->getAuthenticationToken()->setAttribute(
            AuthenticatedVoter::AUTH_TIME_ATTRIBUTE, // 'auth_time'
            $this->clock?->now()->getTimestamp() ?? time()
        );
    }

    public static function getSubscribedEvents(): array
    {
        return [SecurityEvents::INTERACTIVE_LOGIN => ['onInteractiveLogin', 256]];
    }
}
Priorität 256, damit ein Listener, der die Propagation stoppt, das Token nicht ohne Stempel zurücklassen kann. Der Name auth_time ist absichtlich von OpenID Connect geliehen.

Token-Attribute werden in die Session serialisiert, der Stempel übersteht also jeden Request ganz von selbst. Der AuthenticatedVoter vergleicht auth_time dann mit der konfigurierten Lebensdauer. Drei Details solltest du kennen:

  • Remember-me kommt nie durch. Ein Remember-me-Token bekommt den Stempel wie jedes andere, aber der Voter prüft zuerst isFullFledged(). Ein Cookie, das jeder mit Zugriff auf den Browser wiederverwenden kann, beweist gar nichts.
  • Kein Stempel heißt nein. Tokens ohne auth_time, etwa von einem eigenen, nicht interaktiven Authenticator, werden abgelehnt. Sicherer Standard.
  • Auch Impersonation hat keinen Stempel. Wenn du per switch_user in einen anderen User wechselst, bestehst du die Recency-Prüfungen in seinem Namen nicht. Fair.

Ach ja, und der Voter sagt jetzt, welches Attribut er abgelehnt hat. Das taucht im Profiler auf, und der nächste Teil baut darauf auf.

Ein 403 ist keine Benutzeroberfläche

Der User klickt also drei Stunden nach dem Login auf „Konto löschen“ und bekommt … einen 403. Danke, Symfony. Korrekt, aber nutzlos: Niemand sagt ihm, dass sein Passwort das Problem lösen würde.

Jetzt denkst du vielleicht: Dann nehmen wir eben den Entry Point der Firewall und leiten auf die Login-Seite um. Genau das hat das Team ausprobiert, und es funktioniert nicht. form_login leitet auf login_path um, die Login-Seite sieht einen angemeldeten User und schickt ihn direkt zurück. HTTP Basic schickt die gecachten Zugangsdaten einfach noch einmal, ohne zu fragen. Du würdest eine Redirect-Schleife bauen.

Deshalb bringt 8.2 ein eigenes Interface mit, ReAuthenticationEntryPointInterface. Sein Docblock bringt es schön auf den Punkt: Der normale Entry Point beantwortet die Frage „Wer bist du?“, und eine bestehende Session hat sie schon beantwortet. Hier ist der User bekannt, und wir wollen nur einen frischen Nachweis. Deshalb bekommt er das Token statt einer Exception:

src/Security/ConfirmPasswordEntryPoint.php
namespace App\Security;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\EntryPoint\ReAuthenticationEntryPointInterface;

final class ConfirmPasswordEntryPoint implements ReAuthenticationEntryPointInterface
{
    public function __construct(private UrlGeneratorInterface $urlGenerator)
    {
    }

    public function startReAuthentication(Request $request, TokenInterface $token): Response
    {
        return new RedirectResponse($this->urlGenerator->generate('app_confirm_password'));
    }
}

Häng ihn in die Firewall:

config/packages/security.yaml
security:
    firewalls:
        main:
            form_login: {}
            re_authentication_entry_point: App\Security\ConfirmPasswordEntryPoint
Wenn dein Haupt-Entry-Point das Interface schon implementiert (der neue OIDC-Authenticator tut das), nimmt Symfony ihn auch ohne diese Zeile.

Wenn ab jetzt der Zugriff verweigert wird und nur das Recency-Attribut gescheitert ist, speichert Symfonys Exception-Listener die angefragte URL als Target Path und ruft deinen Entry Point auf, statt den 403 zu rendern. Eine fehlende Rolle bekommt weiterhin den 403, und das ist genau richtig: Kein noch so oft eingetipptes Passwort gibt dir ROLE_ADMIN.

Ein Sicherheitsnetz zur Compile-Zeit: Setzt du die Option auf einer stateless Firewall, weigert sich der Container zu bauen. Ohne Session gibt es nichts zu stempeln, die Re-Authentifizierung würde also endlos im Kreis laufen.

Die Confirm-Password-Seite

Symfony bringt den User bis zur Tür, die Seite dahinter gehört dir. Sie muss nur zwei Dinge tun: das Passwort prüfen und Symfony dazu bringen, das Token neu zu stempeln. Beim zweiten Teil wird es elegant. Security::login() führt den normalen Form-Login-Authenticator aus. Der ist interaktiv, also feuert er das InteractiveLoginEvent, und das erneuert auth_time. Und weil form_login den gespeicherten Target Path berücksichtigt, leitet die Erfolgs-Response schon dorthin zurück, wo der User eigentlich hinwollte.

src/Controller/SecurityController.php
#[Route('/confirm-password', name: 'app_confirm_password')]
public function confirmPassword(
    Request $request,
    Security $security,
    UserPasswordHasherInterface $hasher,
): Response {
    $form = $this->createForm(ConfirmPasswordType::class);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $user = $this->getUser();

        if ($hasher->isPasswordValid($user, $form->get('password')->getData())) {
            // führt den form_login-Authenticator aus: InteractiveLoginEvent feuert,
            // auth_time wird erneuert und der gespeicherte Target Path berücksichtigt
            return $security->login($user, 'form_login')
                ?? $this->redirectToRoute('app_account');
        }

        $form->addError(new FormError('Das Passwort stimmt nicht.'));
    }

    return $this->render('security/confirm_password.html.twig', [
        'form' => $form,
    ]);
}
Ein Passwortfeld, kein E-Mail-Feld: Wir wissen ja schon, wer das ist.

Zwei Dinge, bevor du es auslieferst

Beide Einschränkungen sind im Pull Request dokumentiert, und beide ergeben Sinn, wenn du kurz darüber nachdenkst:

  • Nur sichere Requests speichern die Rücksprung-URL. Ist der erste geschützte Request der POST des Löschen-Buttons selbst, wird kein Target Path gespeichert, und der User landet nach der Bestätigung auf deiner Standardseite. Schütz den GET, der das Bestätigungsformular zeigt, nicht nur den POST, der die Aktion ausführt.
  • Expressions lösen den Entry Point nicht aus. allow_if: "is_recently_authenticated()" in access_control gibt dir einen schlichten 403. Der Redirect-Zweig prüft auf das Attribut, nicht auf die Expression. Nimm das Attribut überall dort, wo du den schönen Ablauf willst.

Mehr als die Uhr: dein eigener Trust Resolver

Der letzte PR im Stapel hat die Entscheidung aus dem Voter in den AuthenticationTrustResolver verschoben, der dafür isAuthenticatedRecently() und isAuthenticatedVeryRecently() bekommen hat. Zeit ist nur die Standardstrategie. Wenn „kürzlich“ in deiner Domäne etwas anderes bedeutet, erweitere den Resolver. Der Release-Post zeigt eine Bank, die nur einem Hardware-Key vertraut:

src/Security/BankTrustResolver.php
final class BankTrustResolver extends AuthenticationTrustResolver
{
    public function isAuthenticatedVeryRecently(?TokenInterface $token = null): bool
    {
        if (!$this->isFullFledged($token)) {
            return false;
        }

        $proofs = $token->getAuthenticationProofs();

        return isset($proofs[AuthenticationMethod::HARDWARE_KEY])
            && time() - max($proofs) <= 300;
    }
}
Der isFullFledged()-Check bleibt im Voter, egal was du hier zurückgibst: Ein eigener Resolver kann Remember-me nicht zu Sudo hochstufen.

Die Nachweise kommen aus einem AuthenticationMethodBadge, das Authenticators anhängen. Die Methodennamen folgen RFC 8176, demselben Vokabular, das OpenID Connect für seinen amr-Claim verwendet. Apropos: Der neue OIDC-Login-Authenticator liest den auth_time-Claim des ID-Tokens und stempelt diesen Wert, damit eine stille Re-Authentifizierung beim Provider nicht als frischer Login zählt. Außerdem implementiert er den Re-Authentication-Entry-Point selbst und schickt den User mit prompt=login zurück zum Provider. Null Konfiguration für alle, die OIDC nutzen.

Wie geht's weiter?

Symfony 8.2 erscheint im November. Wenn du jetzt schon damit spielen willst, reicht composer require symfony/security-bundle:8.2.x-dev in einer Wegwerf-App, und die vier PRs (#66014, #66015, #66016, #66035) lesen sich sehr gut, wenn du die Begründung hinter jedem Detail wissen willst.

Unser Vorschlag: Öffne deine Controller und such die drei oder vier Aktionen, die wehtun würden, wenn die falsche Person sie anklickt. E-Mail ändern, Passwort ändern, Auszahlungsdaten, Konto löschen, API-Keys erzeugen. Setz IS_AUTHENTICATED_RECENTLY drauf, bau eine Confirm-Password-Seite, fertig. Das ist ein Nachmittag, und er schließt eine Lücke, die die meisten Apps seit ihrem ersten Login-Formular haben.

Und wenn du seit Jahren einen selbst gebauten Sudo Mode betreibst (der Issue-Thread ist voll von Leuten, die das tun), schau nach, wie viel davon du jetzt löschen darfst. Code löschen ist der schönste Teil eines Upgrades.

Inhalt