Integración de SecurityBundle de Symfony2 y FOSUserBundle

symfony.jpg
Solucionex
05
Jun 12

Este artículo es una traducción libre del texto publicado en el blog Logic Exception en el que se pretende explicar cómo interactúa SecurityBundle de Symfony2 con FOSUserBundle.

Con la finalidad de incluir nuevas funcionalidades al proceso de inicio de sesión, en concreto, realizar un seguimiento de intentos de acceso, y sin saber nada acerca de SecurityBundle de Symfony2, intentamos entender qué hace el código de este Bundle y en este caso, cómo interactúa con FOSUserBundle.

Partiendo de una configuración de seguridad básica:

app/config/security.yml

security: encoders: Symfony\Component\Security\Core\User\User: plaintext role_hierarchy: ROLE_ADMIN: ROLE_USER ROLE_SUPER_ADMIN: ROLE_ADMIN providers: fos_userbundle: id: fos_user.user_manager firewalls: main: pattern: .* form_login: provider: fos_userbundle check_path: /user/login_check login_path: /user/login logout: path: /user/logout anonymous: true dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false login: pattern: ^/user/login$ security: false

Si quieres ampliar la información, aquí puedes ver una configuración de seguridad más completa.

app/config/routing.yml

fos_user_security: resource: "@FOSUserBundle/Resources/config/routing/security.xml" prefix: /user

Como se puede ver arriba, se importan todas las reglas de enrutamiento de seguridad con el prefijo /user.

vendor/bundles/FOS/UserBundle/Resources/config/routing/security.xml

FOSUserBundle:Security:login FOSUserBundle:Security:check FOSUserBundle:Security:logout

Hasta aquí es sencillo de entender. Tenemos un fichero de configuración de seguridad por defecto que importamos a nuestra aplicación con el prefijo /user y todas las rutas posibles (login_path, check_path, etc) definidas en nuestro app/config/config.yml para trabajar con FOSUserBundle. Le echamos ahora un vistazo al SecurityController de FOSUserBundle.

vendor/bundles/FOS/UserBundle/Controller/SecurityController.php

namespace FOS\UserBundle\Controller; use Symfony\Component\DependencyInjection\ContainerAware; use Symfony\Component\Security\Core\SecurityContext; use Symfony\Component\Security\Core\Exception\AuthenticationException; class SecurityController extends ContainerAware { public function loginAction() { $request = $this->container->get('request'); /* @var $request \Symfony\Component\HttpFoundation\Request */ $session = $request->getSession(); /* @var $session \Symfony\Component\HttpFoundation\Session */ // get the error if any (works with forward and redirect -- see below) if ($request->attributes->has(SecurityContext::AUTHENTICATION_ERROR)) { $error = $request->attributes->get(SecurityContext::AUTHENTICATION_ERROR); } elseif (null !== $session && $session->has(SecurityContext::AUTHENTICATION_ERROR)) { $error = $session->get(SecurityContext::AUTHENTICATION_ERROR); $session->remove(SecurityContext::AUTHENTICATION_ERROR); } else { $error = ''; } if ($error) { // TODO: this is a potential security risk (see http://trac.symfony-project.org/ticket/9523) $error = $error->getMessage(); } // last username entered by the user $lastUsername = (null === $session) ? '' : $session->get(SecurityContext::LAST_USERNAME); return $this->container->get('templating')->renderResponse('FOSUserBundle:Security:login.html.'.$this->container->getParameter('fos_user.template.engine'), array( 'last_username' => $lastUsername, 'error' => $error, )); } public function checkAction() { throw new \RuntimeException('You must configure the check path to be handled by the firewall using form_login in your security firewall configuration.'); } public function logoutAction() { throw new \RuntimeException('You must activate the logout in your security firewall configuration.'); } }

En el controlador Security tenemos incluidos los métodos tanto para check como para logout, pero sin definir. Sin embargo funciona correctamente ¿cómo es posible?

Una sola palabra: listeners (o escuchadores).

  • En primer lugar, se genera una clase listener basada en el tipo de autenticación
  • En segundo lugar, esta clase generada registra un proveedor de usuario identificado por una clave definida en la entrada firewall del security.yml. En este caso, nuestro proveedor se llama fos_userbundle. Básicamente, lo que hace es unir SecurityBundle con FOSUserBundle para la autenticación de los valores enviados contra la base de datos
  • A continuación, la clase listener registra el escuchador de autenticación identificado por "security.authentication.listener.form". Este código se encarga de escuchar los intentos de acceso a /user/login
  • Finalmente, FOSUserBundle registra un escuchador de inicio de sesión para el evento security.interactive_login. Este evento indica un inicio de sesión y el escuchador ejecuta el código posterior al acceso del usuario

Volviendo al inicio: todo empieza cuando SecurityBundle se inicia y se cargan nuestras definiciones de seguridad desde el fichero security_factories.xml.

Symfony/Bundle/SecurityBundle/Resources/config/security_factories.xml

Básicamente, el método de carga del SecurityBundle (ver SecurityExtension::load()) simplemente llama al método SecurityExtension::createFirewalls() que a su vez llama al método SecurityExtension::createListenerFactories(). Éste carga a continuación todos los servicios marcados con la etiqueta "security.listener.factory" en el fichero security_factories.xml que se muestra arriba.

Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php

private function createFirewalls($config, ContainerBuilder $container) { // ... // create security listener factories $factories = $this->createListenerFactories($container, $config); // ... }

Los listener perdeterminados son las clases responsables de inicializar los escuchadores según el tipo de autenticación. El SecurityBundle de Symfony incluye múltiples tipos de autenticación predefinidos. Estos tipos incluyen HTTP Basic, HTTP Digest, X509, RememberMe y FormLogin. En este caso, nos centramos en el tipo FormLogin.

Una vez cargados todos los escuchadores predefinidos, se realiza una llamada al método SecurityExtension::createFirewall() donde se realiza una iteración para cada una de las definiciones firewall de nuestro fichero app/config/security.yml (son tres: main, dev y login).

Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php

private function createFirewalls($config, ContainerBuilder $container) { // ... foreach ($firewalls as $name => $firewall) { list($matcher, $listeners, $exceptionListener) = $this->createFirewall($container, $name, $firewall, $authenticationProviders, $providerIds, $factories); $contextId = 'security.firewall.map.context.'.$name; $context = $container->setDefinition($contextId, new DefinitionDecorator('security.firewall.context')); $context ->replaceArgument(0, $listeners) ->replaceArgument(1, $exceptionListener) ; $map[$contextId] = $matcher; } // ... }

Posteriormente, el método SecurityExtension::creationFirewall() inicia los escuchadores de autenticación para cada firewall en particular, llamando al método SecurityExtension::createAuthenticationListeners().

Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php

private function createFirewall(ContainerBuilder $container, $id, $firewall, &$authenticationProviders, $providerIds, array $factories) { // ... // Authentication listeners list($authListeners, $defaultEntryPoint) = $this->createAuthenticationListeners($container, $id, $firewall, $authenticationProviders, $defaultProvider, $factories); // ... }

El método SecurityExtension::createAuthenticationListeners() identifica las clases correspondientes a firewall con solo recorrer las que han sido cargadas en memoria previamente en un orden específico (ver la propiedad SecurityBundle::$listenerPositions y los métodos getPosition() dentro de la clase) buscando después coincidencias con los patrones encontrados bajo la definición firewall (en nuestro caso form_login, logout y anonymous) a una instancia predeterminada (ver método getKey()). Al final, solo hay una clase definida con el patrón form_login que se encuentra en Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginFactory.php.

Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php

private function createAuthenticationListeners($container, $id, $firewall, &$authenticationProviders, $defaultProvider, array $factories) { // ... foreach ($this->listenerPositions as $position) { foreach ($factories[$position] as $factory) { $key = str_replace('-', '_', $factory->getKey()); if (isset($firewall[$key])) { $userProvider = isset($firewall[$key]['provider']) ? $this->getUserProviderId($firewall[$key]['provider']) : $defaultProvider; list($provider, $listenerId, $defaultEntryPoint) = $factory->create($container, $id, $firewall[$key], $userProvider, $defaultEntryPoint); $listeners[] = new Reference($listenerId); $authenticationProviders[] = $provider; $hasListeners = true; } } } // ... }

Nota: El método SecurityExtension::createAuthenticationListeners() también crea un escuchador para autenticaciones anónimas. El ćodigo ha sido excluido por simplificar.

Una vez ha sido identificada una clase predefinida apropiada, se llama al método FormLoginFactory::create(). Esto hace que se registre un escuchador de autenticación "security.authentication.listener.form" que intercepta los intentos de conexión.

El método FormLoginFactory::create() también realiza una llamada a createAuthProvider() que es el responsable de registrar nuestro proveedor de usuario (fos_userbundle). Esta clase se encarga de verificar las credenciales de inicio de sesión contra la base de datos.

La clase FormLoginFactory también declara nuestro identificador de escuchador security.authentication.listener.form (ver FormLoginFactory::getListenerId()).

Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml

Symfony\Component\Security\Http\Firewall\UsernamePasswordFormAuthenticationListener

Como se puede ver en el security_listeners.xml, nuestra clase escuchador es Symfony\Component\Security\Http\Firewall\UsernamePasswordFormAuthenticationListener. Las cosas se ponen más fáciles a partir de aquí. Cualquier llamada a nuestro método UsernamePasswordFormAuthenticationListener::handle() activa el método attemptAuthentication(), que es donde comienza la interacción entre FOSUserBundle y SecurityBundle.

En primer lugar, se llama al método authenticate() que se encarga de la autenticación, que realiza una llamada a nuestro proveedor de usuario. Esta es la clase UserManager, incluida en el paquete FOSUserBundle.

Symfony/Component/Security/Http/Firewall/UsernamePasswordFormAuthenticationListener.php

protected function attemptAuthentication(Request $request) { // ... return $this->authenticationManager->authenticate(new UsernamePasswordToken($username, $password, $this->providerKey)); }

A continuación, en base al valor que retorna la llamada a authenticate(), se llama a los métodos onSuccess() o onFailure(). Si el usuario accede con éxito, el método AbstractAuthenticationListener::onSuccess() lanza un evento identificado por la constante SecurityEvents::INTERACTIVE_LOGIN.

Symfony/Component/Security/Http/Firewall/AbstractAuthenticationListener.php

private function onSuccess(GetResponseEvent $event, Request $request, TokenInterface $token) { // ... if (null !== $this->dispatcher) { $loginEvent = new InteractiveLoginEvent($request, $token); $this->dispatcher->dispatch(SecurityEvents::INTERACTIVE_LOGIN, $loginEvent); } // ... }

La constante SecurityEvents::INTERACTIVE_LOGIN tiene un valor de security.interactive_login con el que podemos localizar fácilmente el lugar en el que sucede el registro del escuchador de inicio de sesión:

vendor/bundles/FOS/UserBundle/Resources/config/security.xml

FOS\UserBundle\Security\Encoder\EncoderFactory FOS\UserBundle\Security\InteractiveLoginListener %security.encoder.digest.class% %fos_user.encoder.encode_as_base64% %fos_user.encoder.iterations%

Y aquí está el código personalizado del listener asociado al evento security.interactive_login cuyo propósito es actualizar la fecha del último acceso de los usuarios en nuestra aplicación, después de un inicio de sesión correcto.

FOS/UserBundle/Security/InteractiveLoginListener.php

namespace FOS\UserBundle\Security; use FOS\UserBundle\Model\UserManagerInterface; use FOS\UserBundle\Model\UserInterface; use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; use DateTime; class InteractiveLoginListener { protected $userManager; public function __construct(UserManagerInterface $userManager) { $this->userManager = $userManager; } public function onSecurityInteractiveLogin(InteractiveLoginEvent $event) { $user = $event->getAuthenticationToken()->getUser(); if ($user instanceof UserInterface) { $user->setLastLogin(new DateTime()); $this->userManager->updateUser($user); } } }