Symfony 2: User-Switch-Verhalten ändern mittels Compiler-Pass

Auf die Anforderung, dass es – z.B. für einen Supportmitarbeiter – möglich sein soll sich mit dem Benutzerkonto eines Anwenders an einer Webapplikation anzumelden, trifft man häufig. Auf diesem Weg lassen sich schnell Probleme und Fehler nachvollziehen und bei Bedarf können individuelle Einstellungen direkt vom Supporter vorgenommen werden.

Die denkbar unsicherste Methode, um den Identitätswechsel zu erreichen ist natürlich die Weitergabe von Logininformationen und Passwörtern, was man aus Sicherheitsgesichtspunkten unter allen Umständen vermeiden sollte. Symfony bietet aber genau zu diesem Zweck den switch_user-Parameter. Damit lässt sich z.B. in einer Übersicht im Backend-Bereich zu jedem Benutzerkonto ein „Login als User“-Link anbieten, der es Admins mit Backend-Zugriff erlaubt die Rolle des Anwenders zu übernehmen und sich „als dieser Anwender“ in der Applikation zu bewegen. Natürlich sollte man so ein mächtiges Feature immer mit Bedacht anwenden und alle Mitarbeiter über den Umfang der damit einhergehenden Anwendungsfälle – und Missbrauchsmöglichkeiten – informieren – „with great power comes great responsibility“.

Typische Anpassungswünsche

Beim Einsatz dieses netten Features kommt man relativ schnell an den Punkt, dass man am Verhalten der User-Switch-Komponente gerne etwas ändern möchte, wie z.B. die beiden folgenden Links zeigen:

http://stackoverflow.com/questions/17192294/add-custom-logic-to-internal-symfony-classes-like-switchuserlistener-or-template

http://stackoverflow.com/questions/9118981/redirect-after-user-switching-impersonating-in-symfony-2

Änderungswünsche für die standardmässige Symfony-Funktionalität an dieser Stelle sind z.B.:

  • Programmgesteuerter Redirect nach dem User-Switch
    In Abhängigkeit von Sessioninhalten oder Navigationspfaden soll ein bestimmter Redirect ausgeführt werden.
  • Verhindern unerwünschter User-Switches zur Vermeidung von privilege escalation
    Bei mehreren Usern mit ROLE_ALLOWED_TO_SWITCH sollte man darauf achten, dass es jedem User mit dieser Rolle erlaubt ist die Identität jedes anderen Users anzunehmen. Stellen wir uns z.B. eine Applikation mit Backend-Zugang für Support-Mitarbeiter und Admins vor, wobei die Admins ein deutlich größerer Rechtespektrum als die Support-Mitarbeiter haben. Die Support-Mitarbeiter werden aber trotzdem mit ROLE_ALLOWED_TO_SWITCH ausgestattet. In dieser Konstellation gibt es zunächst mal nichts in Symfony 2 was einen Support-Mitarbeiter daran hindert zum Admin zu switchen, da Symfony 2 nichts davon weiß, das wir den Admin-Zugang gerne „mächtiger“ als den Support-Mitarbeiter-Zugang hätten und deshalb keine User-Switch vom Admin zum Support-Mitarbeiter möglich sein sollte. Wenn die Applikation keinen Link anbietet in dem der switch_user-Parameter mit dem Nutzernamen des Admins belegt wird, muss sich der Support-Mitarbeiter nur einen solchen Link zusammenbasteln. Dazu sind heute viele Menschen – und erst recht potentielle Angreifer – in der Lage. Damit hätten wir an dieser Stelle einen echten Angriffsvektor zur privilege escalation, den wir schliessen sollten.
  • Logging zum User-Switch-Vorgang

Auf den User-Switch lauschen

Den ersten Ansatz den Symfony 2 bietet, um beim User-Switch einzugreifen, basiert auf dem integrierten Event-System, das auf der EventDispatcher-Komponente aufbaut. Um einen Event abzufangen muss man einen entsprechenden Event-Listener implementieren und als Service registrieren. Nach dem erfolgreichen Wechsel zu einem anderen User und nach dem Logout aus dem Konto des anderen Users über den exit-Parameter wird das Event security.switch_user geworfen, das man z.B. wie folgt abfangen kann:

app/config/config.yml

...    
services:
    bitfriends.demo.switch_user_listener:
        class: BitFriends\DemoBundle\EventListener\SecurityListener
        tags:
            - { name: kernel.event_listener, event: security.switch_user, method: onSwitchUser }
...
src/BitFriends/DemoBundle/EventListener/SecurityListener.php
namespace BitFriends\DemoBundle\EventListener;

use Symfony\Component\Http\Event\SwitchUserEvent;
    
class SecurityListener
{
    public function onSwitchUser(SwitchUserEvent $event)
    {
        $request = $event->getRequest();
        $targetUser = $event->getTargetUser();
    }  
}

Das SwitchUserEvent enthält standardmässig das aktuelle Request-Objekt und das (Target-)-User-Objekt zu dem gewechselt werden soll. Bei Bedarf kann man in die SecurityListener-Instanz auch weitere Elemente aus dem Service-Container injecten. Möchte man z.B. eine Mail verschicken, wäre es ganz hilfreichen, wenn man im Listener Zugriff auf eine Mailer-Instanz hätte, die wir einfach an den Konstruktor übergeben (lassen).

app/config/config.yml

...    
services:
    bitfriends.demo.switch_user_listener:
        class: BitFriends\DemoBundle\EventListener\SecurityListener
        arguments:
           - "@mailer"
        tags:
            - { name: kernel.event_listener, event: security.switch_user, method: onSwitchUser }
...

src/BitFriends/DemoBundle/EventListener/SecurityListener.php

<?php namespace BitFriends\DemoBundle\EventListener; use Symfony\Component\Http\Event\SwitchUserEvent; class SecurityListener { private $mailer; public function __construct($mailer) { $this->mailer = $mailer;
    }

    public function onSwitchUser(SwitchUserEvent $ev)
    {
        // Hier könnten wir jetzt z.B. eine Mail verschicken
    }  
}

Den SwitchUserListener überschreiben

Das ist ja schonmal was. Allerdings stößt man beim Einsatz eines Event-Handlers relativ schnell an Grenzen der Machbarkeit oder man muss im On-Event-Callback schlichtweg sehr hässliche Dinge tun, um das zu erreichen was man möchte.

So wird z.B. der ’security.switch_user‘-Event im SwitchUserListener (namespace Symfony\Component\Security\Http\Firewall) – der innerhalb des Standard-Frameworks für die Durchführung des User-Switches zuständig ist – erst nach dem eigentlichen User-Wechsel aufgerufen und man hat keinerlei Möglichkeit im Event-Handler auf das Ergebnis dieses User-Wechsel einzuwirken bzw. ihn zu steuern. Natürlich ist das auch nicht Sinn und Zweck eines Event-Handlers und man sollte es deshalb auch nicht damit versuchen. Es ist also z.B. keine gute Idee im Event-Handler einen HTTP-Redirect durchzuführen oder den User in der Session zu manipulieren.

Symfony wäre aber nicht Symfony, wenn wir für weitergehende „Anpassungswünsche“ nicht noch andere Optionen hätten. Wie uns das Cookbook lehrt, gibt es die Möglichkeit für einzelne Elemente des Service Containers die zugrundeliegende Klassendefinition zu überschreiben, falls der Klassenname in der Service-Container-Konfiguration als Parameter vorliegt. Aber wir haben diesmal Glück. Nach kurzer Suche in den Quellen findet man tatsächlich den Parameter ’security.authentication.switchuser_listener.class‘ Und damit können wir die Klasse, die den User-Switch tatsächlich durchführt – den SwitchUserListener – direkt austauschen gegen eine Klasse unserer Wahl:

app/config/config.yml

parameters:
    security.authentication.switchuser_listener.class: BitFriends\DemoBundle\EventListener\SwitchUserListener

Bei der Implementierung dieser Klasse orientiert man sich an der Standardklasse Symfony\Component\Security\Http\Firewall\SwitchUserListener, insbesondere muss man natürlich die handle-Methode für das Symfony\Component\Security\Http\Firewall\ListenerInterface implementieren. Der Konstruktor muss alle Parameter berücksichtigen, die das Framework standardmäßig übergibt.

src/BitFriends/DemoBundle/EventListener/SwitchUserListener.php

<?php
namespace BitFriends\DemoBundle\EventListener;

...
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
...
    
class SwitchUserListener implements ListenerInterface
{
...
    public function __construct(SecurityContextInterface $securityContext,
                                UserProviderInterface $provider,
                                UserCheckerInterface $userChecker,
                                $providerKey,
                                AccessDecisionManagerInterface $accessDecisionManager,
                                LoggerInterface $logger = null,
                                $usernameParameter = '_switch_user',
                                $role = 'ROLE_ALLOWED_TO_SWITCH',
                                EventDispatcherInterface $dispatcher = null)
    {
        ...
    }
    ...
    public function handle(GetResponseEvent $ev)
    {
        /**
         * Hier können wir den User-Switch ganz individuell nach unserem Belieben "behandeln"
         *
         * Natürlich orientieren wirs uns dabei an Symfony\Component\Security\Http\Firewall\SwitchUserListener
         * .. oder erweitern diese Klasse sogar
         */
        ...
    }
...
}

Wie oben schon erwähnt, müssen wir beim Konstruktor unseres SwitchUserListeners die Parameter entgegennehmen, die das Framework standardmässig übergibt. Was machen wir aber, wenn uns das nicht reicht ? Wir hatten ja z.B. die Anforderung das Weiterleitungs-Ziel nach dem User-Switch zu beeinflussen. Dazu wäre es vielleicht ganz hilfreich, wenn wir Zugriff auf die router-Komponente hätten. Oder wir haben vielleicht einen ganz tollen Service in den Service-Container injected, den wir auch beim User-Switch gerne benutzen würden.

Bei solchen Anforderungen kommen wir an die Grenze dieses Verfahrens: Zwar können wir für Services aus dem Standardframework, für die in der Containerkonfiguration die Klassennamen parametrisiert sind, eigene Klassennamen konfigurieren. Wir können aber nicht weitere Konstruktorparameter hinzufügen oder sonstige Methodenaufrufe triggern, um weitere Abhängigkeiten zu injecten. Dazu müssen wir eine noch mächtigere Eingriffsmöglichkeit in den DI-Container von Symfony nutzen: den Compiler-Pass.

Den Container (mit-)kompilieren

Wir wir dem einschlägigen Artikel aus dem Symfony-Cookbook entnehmen, lässt sich ein Compiler-Pass in der Bundle-Klasse hinzufügen:

src/BitFriends/DemoBundle/BitFriendsDemoBundle.php

<?php namespace BitFriends\DemoBundle; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use BitFriends\DemoBundle\DependencyInjection\Compiler\OverrideServiceCompilerPass; class BitFriendsDemoBundle extends Bundle { public function build(ContainerBuilder $container) { parent::build($container); $container->addCompilerPass(new OverrideServiceCompilerPass());
    }    
}

In der Klasse OverrideServiceCompilerPass überschreiben wir den switchuser_listener aus dem Symfony-Standard-Framework und injecten gleichzeitig noch die Router-Komponente aus dem Service-Container mittels Setter Injection:

src/BitFriends/DemoBundle/DependencyInjection/Compiler/OverrideServiceCompilerPass.php

<?php namespace Acme\DemoBundle\DependencyInjection\Compiler; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; class OverrideServiceCompilerPass implements CompilerPassInterface { public function process(ContainerBuilder $container) { $definition = $container->getDefinition('security.authentication.switchuser_listener');
        $definition->setClass('Acme\DemoBundle\EventListener\SwitchUserListener');
        $definition->addMethodCall('setRouter', array(new Reference('router')));
    }
}

Der Compiler-Pass erlaubt uns die Service-Definitionen für den DI-Container abzuändern, Klassennamen auszutauschen, Konstruktor-Argumente anzugeben und beliebige Methoden bei den Services aufzurufen. Symfony nutzt standardmässig diverse Compiler-Passes, um die Services im DI-Container zu instanziieren, Parameter auszuwerten bzw. weiterzureichen und die Konfiguration zu optimieren und zu validieren. Die einzelnen Passes sind dabei in unterschiedliche Durchgänge gruppiert, die nacheinander abgearbeitet werden. Indem man den eigenen Compiler-Pass einer bestimmten Gruppe zuordnet, kann man steuern, wann der Compiler-Pass beim Kompilieren des DI-Containers durchgeführt wird.

Das Kompilieren des Containers ist ein zeitaufwändiger und ressourcenintensiver Vorgang, der mehrere Sekunden Laufzeit beansprucht. Wenn Symfony das bei jedem HTTP-Request auf die Web-Applikation machen würde, hätte das schwere Performanceprobleme zur Folge. Deshalb wird der komplett kompilierte Container gecached. Bei Änderungen an der Konfiguration muss der Cache invalidiert und der Container neu kompiliert werden. Darum müssen wir uns aber normalerweise nicht kümmern, das macht Symfony automatisch in der Dev-Umgebung. Sollte es aber doch mal irgendwelche seltsamen, unverständlichen Probleme geben, lohnt sich der Versuch den Cache zu leeren.

tl;dr

  • Mit dem Parameter switch_user bzw. dem SwitchUserListener aus der Firewall-Komponente lässt sich in Symfony einfach eine Funktion umsetzen, die es einem User – im Rahmen der Applikation – erlaubt die Identität eines anderen Users zu übernehmen
  • Beim User-Switch wird ein Event („security.switch_user„) abgesetzt, auf das man mittels Event-Listener lauschen kann
  • Möchte man die Klasse SwitchUserListener aus dem Standardframework überschreiben, kann man dazu den Konfigurationsparameter security.authentication.switchuser_listener.class nutzen
  • Noch weitergehende Eingriffsmöglichkeiten erhält man durch eigene Compiler-Passes, mit denen man Aufbau und Inhalt des Service-Containers direkt beeinflussen kann.