Introducción
El objetivo de este tutorial es describir una solución para sincronizar la autenticación de symfony2 (sf2) con los foros de Simple Machines Forums (SMF). A día de hoy no hay ningún bundle de foros para symfony listo para producción. CCDNForumForumBundle es lo mejor que he encontrado pero lamentable su desarrollador no lo mantiene ya. SMF ofrece una serie de ventajas que lo convierte en una solución atractiva:- Comunidad activa
- Actualizaciones frecuentes y fáciles de aplicar
- Modulable
- Customizable
- Gratis y de código libre (licencia BSD)
- Soporta más de 40 idiomas
- Interfaz atractiva e intuitiva
- Gran variedad de funcionalidades para usuarios, moderadores y administradores
Te puede interesar este tutorial si… Utilizas sf2 con el bundle FOSUserBundle para gestionar tu base de usuarios y, además, tu organización requiere de un foro para éstos.
No te va interesar este tutorial si… Pretendes gestionar los usuarios desde SMF. Mi solución deshabilita la posibilidad de registrar usuarios desde el foro, o que éstos puedan modificar sus datos personales. Los datos han de ser actualizados desde sf2 y, a continuación, propagados programáticamente a SMF.
No entra en este tutorial la instalación y configuración de symfony y fosuserbundle. Se da por hecho de que existe una aplicación basada en sf2 con su tabla de usuarios. Tampoco se explicará la configuración de apache, php o mysql. O la instalación de módulos o paquetes de idiomas en SMF. De ahí que utilizaremos los nombres en inglés del apartado de administración de SMF.
Prerequisitos
Para llevar a cabo esta solución se han utilizado los siguientes componentes:- PHP, 5.6.9
- MySql, 5.5.41
- Apache, 2.4
- Symfony, 2.5.12
- FOSUserBundle, 1.3.6
- SMF, 2.0.10
- El paquete curl-easy, 1.1.4
- Sf2: acme.com
- SMF: foros.acme.com
Instalación, configuración y adaptación de SMF
Si tienes instalado y funcionando symfony2, doy por hecho que el despliegue de SMF es pan comido. Basta con descargarlo de la web oficial, descomprimirlo y desplegarlo donde apunte su correspondiente virtual host. En la dirección http://foros.acme.com/install.php, nos indicaran los pasos a seguir para su configuración incial. Una vez instalado, nos autenticamos con las credenciales del usuario administrador para aplicar los siguientes cambios de configuración:- Deshabilitar el registro de nuevos usuarios en SMF: Admin » Features and options » Members » Registration » Settings » Method of registration employed for new members: Registration disabled
- Deshabilitamos el quick login: Admin » Features and Options » Layout » Show a quick login on every page
- Habilitamos la posibilidad de compartir cookies entres subdominos: Admin » Features and Options » Configuration » Server Settings » Cookies and Sessions » Use subdomain independent cookies
- Deshabilitamos la posibilidad de editar el perfil en SMF: Admin » Features and Options » Members » Regular Members » Mofify » Permissions » General Permissions » Personalize their profiles » Edit their account settings
Hooks para SMF
SMF ofrece a los desarrolladores la posibilidad de extender el comportamiento de los foros para integrarlos con aplicaciones externas. Gracias a una serie de anzuelos (hook en inglés) podemos enganchar bloques de código externo e interferir en acciones como la autenticación. O modificar el comportamiento de algunos de los botones. La lista de hooks disponibles es extensa, pero para nuestra solución sólo necesitaremos cuatro.- integrate_pre_include con el que definiremos la ubicación del archivo con nuestros métodos adicionales.
- integrate_actions con el que modificamos algunas de las acciones de los foros.
- integrate_verify_user con el que verificaremos el usuario actual
- integrate_menu_buttons con el que podremos modificar el comportamiento de los botones del menú
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<?php // If SSI.php is in the same place as this file, and SMF isn't defined, this is being run standalone. if (file_exists(dirname(__FILE__) . '/SSI.php') && !defined('SMF')) require_once(dirname(__FILE__) . '/SSI.php'); // Hmm... no SSI.php and no SMF? elseif (!defined('SMF')) die('<b>Error:</b> Cannot install - please verify you put this in the same place as SMF\'s index.php.'); add_integration_function('integrate_pre_include', '$sourcedir/Subs-SF2Connect.php'); add_integration_function('integrate_actions', 'tweak_user_buttons'); add_integration_function('integrate_verify_user', 'verify_sf2_user'); add_integration_function('integrate_menu_buttons', 'remove_menu_button'); ?> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
<?php if (!defined('SMF')) die('Hacking attempt...'); /* * Configuración */ const SYMFONY_HOST_URL = 'http://acme.com'; const SYMFONY_CURRENT_USER_CONTEXT = '/user/current'; const SYMFONY_LOGIN_CONTEXT = '/user/login'; const SMF_COOKIE_LENGTH = 3600; /* * Fin configuración */ /** * Sobreescribe el comportamiento de los botones de * login y logout con el definido en sf2_login() y smf_logout. * Deshabilita las acciones asociadas al login rápido (login2) * y de registro. **/ function tweak_user_buttons(&$actionArray) { $actionArray['login'] = array('Subs-SF2Connect.php', 'sf2_login'); $actionArray['logout'] = array('Subs-SF2Connect.php', 'smf_logout'); unset($actionArray['login2']); unset($actionArray['register']); } /** * Método principal del conector, se lanza cada vez que se carga la página. * Si el usuario está autenticado en SMF, no hace nada más. * En caso contrario, preguntará si lo está en symfony. Si afirmativo, * queda autenticado. En caso contrario, queda marcado como invitado. * **/ function verify_sf2_user() { global $cookiename, $db_name, $db_prefix, $db_user, $db_passwd, $db_server, $sourcedir; $isAuthenticatedInSmf = isset($_COOKIE[$cookiename]); if(!$isAuthenticatedInSmf && !$_SESSION['user_is_guest']) { //check if user is authenticated in SF2 $url = SYMFONY_HOST_URL . SYMFONY_CURRENT_USER_CONTEXT; $curlResource = curl_init(); curl_setopt($curlResource, CURLOPT_URL, $url); curl_setopt($curlResource, CURLOPT_HTTPGET, true); curl_setopt($curlResource, CURLOPT_RETURNTRANSFER, true); // Get returned value as string (don"t put to screen) curl_setopt($curlResource, CURLOPT_HTTPHEADER, array( 'Content-Type: application/json', 'Accept: application/json' )); // Retrieving session ID $symfonyCookie = $_COOKIE['SFSESSID']; if($symfonyCookie === null) { $_SESSION['user_is_guest'] = true; return; } $strCookie = 'SFSESSID=' . $symfonyCookie . '; path=/'; curl_setopt( $curlResource, CURLOPT_COOKIE, $strCookie ); $result = curl_exec($curlResource); curl_close($curlResource); $jsonResponse = json_decode($result); $isAuthenticatedInSF2 = $jsonResponse->authenticated; if(!$isAuthenticatedInSF2) { return; } $conn = new mysqli($db_server, $db_user, $db_passwd); // Check connection if ($conn->connect_error) { die('Connection failed: ' . $conn->connect_error); } $sf2UserEmail = $jsonResponse->email; if($sf2UserEmail === null) { die('Email not found'); } $query = 'SELECT id_member, passwd, password_salt FROM ' . $db_name . '.smf_members WHERE email_address = \'' . $sf2UserEmail . '\''; $result = mysqli_query($conn, $query); $row = mysqli_fetch_row($result); if($row === null) { die('User not found in smf'); } $smfMemberId = $row[0]; $password = $row[1]; $salt = $row[2]; $conn->close(); //Set cookie: require_once($sourcedir . '/Subs-Auth.php'); setLoginCookie(SMF_COOKIE_LENGTH, $smfMemberId, sha1($password . $salt)); $_SESSION['user_is_guest'] = false; return intval($smfMemberId); } else { $_SESSION['user_is_guest'] = false; } } /** * Quita de la plantilla los botones que no queremos. * **/ function remove_menu_button(&$menu_buttons) { if (isset($menu_buttons['logout'])) { unset($menu_buttons['logout']); unset($menu_buttons['register']); } } /** * Manda a la página de login de symfony. * **/ function sf2_login() { $symfonyLoginPage = SYMFONY_HOST_URL . SYMFONY_LOGIN_CONTEXT; $redirect = $symfonyLoginPage . '?referer=' . 'http://$_SERVER[HTTP_HOST]'; header('Location: ' . $redirect); die(); } /** * Desconecta al usuario de smf. * **/ function smf_logout() { require_once($sourcedir . '/LoginOut.php'); Logout(true, false); $response = array('sucess' => true, 'message' => 'user has been logout'); header('Content-Type: application/json'); echo json_encode($response); die(); } ?> |
Para que el invento funcione es importante que el nombre de la cookie de SMF sea el que viene por defecto, SMFCookie956. Se puede comprobar en /Settings.phpBueno, ya tenemos listos los foros. Siguiente paso.
Adaptaciones en Symfony2
Configuración
Lo primero, tenemos que configurar las cookies de symfony.
1 2 3 4 |
framework: session: cookie_domain: .acme.com name: SFSESSID |
1 2 3 4 5 |
acme_user: smf_bridge: enabled: true smf_logout_url: "http://foros.acme.com/index.php?action=logout" smf_cookie_name: "SMFCookie956" |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
<?php namespace ACME\UserBundle\DependencyInjection; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; /** * This is the class that validates and merges configuration from your app/config files * * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html#cookbook-bundles-extension-config-class} */ class Configuration implements ConfigurationInterface { /** * {@inheritDoc} */ public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder(); $rootNode = $treeBuilder->root('acme_user'); $rootNode ->children() ->arrayNode('smf_bridge') ->addDefaultsIfNotSet() ->children() ->booleanNode('enabled')->defaultTrue()->end() ->scalarNode('smf_logout_url')->defaultValue('https://foros.acme.com')->cannotBeEmpty()->end() ->scalarNode('smf_cookie_name')->defaultValue('SMFCookie956')->cannotBeEmpty()->end() ->end() ->end() ->end(); return $treeBuilder; } } ?> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
<?php namespace ACME\UserBundle\DependencyInjection; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\FileLocator; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\DependencyInjection\Loader; /** * This is the class that loads and manages your bundle configuration * * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html} */ class ACMEUserExtension extends Extension { /** * {@inheritDoc} */ public function load(array $configs, ContainerBuilder $container) { $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('services.yml'); $container->setParameter('acme.user.smf_bridge.enabled', $config['smf_bridge']['enabled']); $container->setParameter('acme.user.smf_bridge.smf_logout_url', $config['smf_bridge']['smf_logout_url']) $container->setParameter('acme.user.smf_bridge.smf_cookie_name', $config['smf_bridge']['smf_cookie_name']); } } ?> |
Lógica
SMF ha de saber si el usuario está identificado en symfony. Claro está. Rápido, necesitamos un controlador y una ruta.
1 2 3 |
acme_user_current: pattern: user/current defaults: {_controller: ACMEUserBundle:UserSecurity:currentUser} |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
<?php namespace ACME\UserBundle\Controller; use FOS\UserBundle\Controller\SecurityController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\Security\Core\SecurityContext; class UserSecurityController extends SecurityController { /** * Returns current logged user data * Won't return anything if user is not logged in * * @return JsonResponse * @throws \LogicException */ public function currentUserAction() { if (!$this->container->has('security.context')) { throw new \LogicException('The SecurityBundle is not registered in your application.'); } $response = array('authenticated' => false); if (null === $token = $this->container->get('security.context')->getToken()) { return new JsonResponse($response); } if (!is_object($user = $token->getUser())) { return new JsonResponse($response); } $response = array( 'authenticated' => true, 'name' => $user->getFirstName(), 'lastname' => $user->getLastname(), 'dni' => $user->getDni(), 'email' => $user->getEmail() ); return new JsonResponse($response); } } ?> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
services: acme.authentication.handler.login_success_handler: class: ACME\UserBundle\Security\Authentication\Handler\LoginSuccessHandler arguments: [@router, @security.context] tags: - { name: 'monolog.logger', channel: 'security' } acme.authentication.handler.logout_success_handler: class: ACME\UserBundle\Security\Authentication\Handler\LogoutSuccessHandler arguments: [@router, @security.context, @session] tags: - { name: 'monolog.logger', channel: 'security' } calls: - [ setParameters, [%acme.user.smf_bridge.smf_logout_url%, %acme.user.smf_bridge.smf_cookie_name%, %acme.user.smf_bridge.enabled%] ] |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
<?php namespace ACME\UserBundle\Security\Authentication\Handler; use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\SecurityContext; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Cmf\Component\Routing\ChainRouter as Router; class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface { protected $router; protected $securityContext; protected $adminFirewallName = 'admin'; public function __construct(Router $router, SecurityContext $securityContext) { $this->router = $router; $this->securityContext = $securityContext; } public function onAuthenticationSuccess(Request $request, TokenInterface $token) { $firewallName = $this->securityContext->getToken()->getProviderKey(); $redirectRoute = $firewallName === $this->adminFirewallName ? 'sonata_admin_dashboard' : 'homepage'; $referer = $request->server->get('HTTP_REFERER'); $needle = 'referer='; //Do we have to redirect? if (($pos = strpos($referer, $needle)) !== false) { $referer = urldecode($referer); $redirectTo = substr ($referer, $pos + strlen($needle)); return new RedirectResponse($redirectTo); } return new RedirectResponse($this->router->generate($redirectRoute)); } } ?> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
<?php namespace ACME\UserBundle\Security\Authentication\Handler; use Symfony\Cmf\Component\Routing\ChainRouter as Router; use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface; use Symfony\Component\Security\Core\SecurityContext; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Session\Session; class LogoutSuccessHandler implements LogoutSuccessHandlerInterface { protected $router; protected $securityContext; protected $session; protected $adminFirewallName = 'admin'; protected $smfUrl; protected $smfCookieName; protected $smfEnabled = false; public function __construct(Router $router, SecurityContext $securityContext, Session $session) { $this->router = $router; $this->securityContext = $securityContext; $this->session = $session; } public function onLogoutSuccess(Request $request) { $uri = $request->getUri(); $adminFirewall = false; if (strpos($uri, '/admin') !== false) { $adminFirewall = true; } $redirectRoute = $adminFirewall ? 'sonata_user_admin_security_login' : 'acme_user_login'; $this->securityContext->setToken(null); $request->getSession()->invalidate(1); if ($this->smfEnabled) { $cookies = $request->cookies; foreach ($cookies as $key => $value) { if ($key === $this->smfCookieName) { //usuario autenticado en smf $curlRequest = new \cURL\Request($this->smfUrl); $curlRequest->getOptions() ->set(CURLOPT_TIMEOUT, 5) ->set(CURLOPT_RETURNTRANSFER, true) ->set(CURLOPT_COOKIE, $this->smfCookieName . '=' . urlencode($value)); $response = $curlRequest->send(); //ignora la respuesta json: en el peor de los casos el usuario se quedará logado en smf $feed = json_decode($response->getContent(), true); } } } return new RedirectResponse($this->router->generate($redirectRoute)); } public function setParameters($smfUrl, $smfCookieName, $smfEnabled) { $this->smfUrl = $smfUrl; $this->smfCookieName = $smfCookieName; $this->smfEnabled = $smfEnabled; } } ?> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
... "require": { "php": ">=5.3.3", "symfony/symfony": "2.5.*", "stil/curl-easy": "*" ... }, ... "autoload": { "psr-0": { "": "src/" }, "classmap": ["src/"] }, ... |
Replicación de datos Symfony -> SMF
Hay varias maneras de hacerlo. Una de ella es con triggers en la base de datos. A modo de ejemplo:
1 2 |
DELIMITER $$ CREATE TRIGGER [crayon-673069703dd63182981394 inline="true" ]update_smf |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
<?php namespace AUGC\UserBundle\Admin\Entity; use Sonata\UserBundle\Admin\Entity\UserAdmin as BaseUserAdmin; ... class UserAdmin extends BaseUserAdmin { ... /** * Hook on pre-update operations */ public function preUpdate($user) { ... $entityManager = $container->get('doctrine.orm.entity_manager'); $dateOfbirth = $user->getDateOfBirth() ? : new \DateTime(); $formattedDateOfBirth = $dateOfbirth->format('Y-m-d G:i:s'); $smfUserQuery = "REPLACE INTO smf.smf_members " . "(real_name, member_name, passwd, email_address, is_activated, date_registered, member_ip, birthdate, id_post_group, pm_ignore_list, signature, message_labels, buddy_list, ignore_boards, openid_uri)" . " VALUES ('" . $user->getFirstname() . " " . $user->getLastName() . "', '" . $user->getDni() . "', '" . $smfPassword . "', '" . $user->getEmail() . "', '1', '" . time() . "', '" . $container->get('request')->getClientIp() . "', '" . $formattedDateOfBirth . "', '4', '', '', '', '', '', '');"; try { $entityManager->getConnection()->executeQuery($smfUserQuery); $entityManager->getConnection()->commit(); } catch (\Exception $exception) { $entityManager->getConnection()->rollBack(); $entityManager->getConnection()->close(); throw $exception; } } ?> |