📜 ⬆️ ⬇️

Custom authentication in symfony 2.0 projects

The purpose of this article is to talk about the organization of non-standard authentication for projects based on Symfony 2.0.

Why this may be needed?


This may be necessary in the case when it is difficult to organize access to user accounts via ORM, or authorization is carried out using external resources, such as social networks.


Task


For me, the task looked like this: I had a project on Symfony 1.4 and, with the release of the second version, “my hands itched” to translate it to 2.0, without changing the foundation of the system, i.e. database and the principle of access to it.
')
The data in my project is stored in MongoDB, and access was carried out through its own simplest wrapper, which was able to bind classes to the necessary collections. Attaching a wrapper to symfony 2 was easy.

The task in terms of user authorization was initially limited to obtaining data from MongoDB. The task of improvement is to implement the possibility of authorization for social network accounts.

A bit of theory


Before you start working on your own security extensions for Symfony 2.0, you need to understand how the framework works in general, and the Security component in particular.

I think that everyone who was interested in the second version of the framework is aware that incoming requests are treated as events or events, and are consistently received for processing by listeners. In the course of this processing, the answer appears (the same response), returned to the client.

In general, everything happens like this:
  1. the kernel receives the request;
  2. the request is processed by the security system;
  3. the request processes the “router”, the requested controller is determined;
  4. Action and related response preparation processes are performed.
  5. the generated response passes its part of the path through the handlers, where the last adjustments are made.

We are interested in stage 2 and, in part, 3.

How the security system works is a separate song. The process looks like this:
  1. The general security listener receives the request event and distributes it to the “child” authentication listeners.
  2. listeners form a token and try to authorize it with an AuthenticationManager;
  3. AuthenticationManager selects the appropriate authentication provider and returns it token for verification.
  4. The authentication provider returns to the manager, and then, in turn, the listener is an authorized (or unauthorized) token. At this stage, UserProvider is activated, which works with user data.
  5. The listener, depending on the result returned by the AuthenticationManager and its own code, either “slips” the authorized token SecurityContex, or forms a response (Response), or does both. If the listener generates a response, the request event will not go to the “router” for processing and there will be no call to the controller.
  6. then authorization takes place, I did not understand this and subsequent stages, but the principle is generally similar to authentication, as I understood it.

In comparison with 1.x, where all the described processes occurred directly in the Actions, the scheme seems to be overloaded, unnecessarily complex. However, this structure gives us the opportunity not to do all the work of organizing security in our application, allowing you to change only the necessary part, and entrust the rest of the work to the framework.

Step 0 - we study the documentation.


Whatever you want to get from the security system of symfony 2.0, before you get down to business you need to update the relevant documentation. Do not do without the chapters of the Books Security and Service Container . If you want to read in your own language, you can study this article .

In fact, if you do not want to receive information about users from “strange” sources, then it’s not necessary to read further. Working with the base through Doctrine is beautifully described in the listed articles.

Step 1 - its own user base.


Symfony 2.0 out of the box has a fairly large set of authorization tools. There is, as the simplest option with authorization for a username / password pair, and more exotic options, such as authorization by certificates.

First of all, I need to organize the most common authorization by login and password, but take data from my own database on MongoDB and through my own wrapper.

In fact, this is the easiest part of the task. First we need a class that implements the UserInterface interface.

namespace MyBundle\Models; use Mh\Mongo\Model\Base; use Symfony\Component\Security\Core\User\AdvancedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; class User extends Base implements UserInterface { public function getRoles() { return $this->credentials; } public function getPassword() { return $this->passw; } public function getSalt() { return $this->salt; } public function getUsername() { return $this->uname; } public function eraseCredentials() { } public function equals(UserInterface $user) { return $this->getUsername() === $user->getUsername(); } public function __toString() { return $this->uname; } } 


The class is very simple, in this case, all the functionality for accessing the document data from MongoDB is mapped to the parent class, consideration of which is not the topic of this article. The implementation of the UserInterface interface provides the framework's security system with the ability to retrieve some of the necessary user data and compare user objects with each other.

It is worth paying attention to the getRoles method, in the article above, this method returns the entities Role, we have the same output array with simple strings, which is acceptable for the system. If we consider roles as groups that may contain a variable set of rights, then we need to return objects compatible with RoleInterface, in our case, the role is a set of rights, so we have enough string values.

Next, you need a class that implements the UserProviderInterface interface, which allows you to get objects of the above class.

 namespace MyBundle\Security; use Mh\Mongo\MongoBundle\ConnectionManager; use MyBundle\Models\User; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use \Exception; use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; class UserProvider implements UserProviderInterface { protected $collection; protected $db; protected $field; protected $logger; //  ,  ,      //  ,          // . public function __construct(array $params, ConnectionManager $cm, LoggerInterface $logger) { //         $this->db = $cm->getConnection($params['confname']); $this->collection = $this->db->selectCollection($params['collection']); //       ,   $this->field = $params['field']; if (!$this->field || !$this->collection) { throw new Exception("Invalid parameters"); } //     logger',   DI $this->logger = $logger; } //        , //    . public function loadUserByUsername($uname) { //      . $this->logger->debug("user load request. name: $uname"); //         User //   . $user = $this->collection->findOne(array($this->field => $uname)); //  . ,    . if (!isset($user->uname) || $user->uname !== $uname) { throw new UsernameNotFoundException(sprintf('User "%s" does not exist.', $uname)); } //     return $user; } //       . //        . public function refreshUser(UserInterface $user) { if (!($user instanceof User)) throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user))); $this->logger->info("refresh from mongo"); $_user = $this->collection->findOne(array('_id' => $user->_id)); if ($_user && $_user instanceof User) $this->logger->info("roles: " .implode(', ',$_user->roles)); else throw new UsernameNotFoundException(sprintf('User "%s" does not exist.', $user->uname)); return $_user; } //    . public function supportsClass($class) { $this->logger->debug("support checking: $class"); if ($class == 'MyBundle\Models\User') return true; return false; } } 


Now you need to register the provider as a service and “teach” the security component to use it.

In the services.yml of our bundle we will add data about the service and parameters for it:

 parameters:
   my.users:
     confname: default
     collection: user
     field: uname

 services:
   my.users.prov:
     class: MyBundle \ Security \ UserProvider
     arguments: [% my.users%, @ mongo.manager, @ monolog.logger]


In the security.yml application, we write:

 security:
     encoders:
         Symfony \ Component \ Security \ Core \ User \ User: plaintext
         # for ease of launch, you can store an unencrypted password in the database		
         MyBundle \ Models \ User: plaintext

     providers:
	 # now declare a new user source (you can assign any name)
	 # and write in it the name of the service that will give the data 
         about users
         mongobase:
           id: my.userprov
 ...


There is one feature that is not explicitly mentioned anywhere (or I did not pay attention). If more than one user provider is declared in security.yml, the first one will be used for authentication, if not the other provider is specified directly.

Actually after that we can already start to login using data from MongoDB. In my case, the database already has data from a previous version of the project. How to make an entry point, described in the above articles, I think it makes no sense to write about it again.

Step 2 - OAuth Authentication


As an innovation in the new version of the project, I decided to add the ability to authorize users for account registration in social. networks. As an example, I will talk about authentication and registration through all the beloved "beloved" soc. VKontakte network.

Creating your own authentication method in Symfiny 2.0 can be considered documented. There is an official article telling about the implementation of authentication using WSSE. Starting work on the functionality I needed, I was guided by this article. Principles of work with soc. I gained a network from this article on Habré.

Now a little analysis of the information received. The authentication described for the example in the official article is not very similar to what I need. The process of authenticating a user with a Vkontakte account is rather similar to the usual (based on login / password) authentication, however, we have neither a login nor a password. After thinking about it, I decided to examine the source code of symfony, in particular, how the regular authorization is arranged through the form (form_login).

It turns out that the built-in AuthenticationListeners and AuthenticationProviders inherit from abstract classes that take on most of the chores associated with directly managing authentication in Symfony, working with a user session, etc. We can also use these classes to facilitate our work .

Let's get started

As in the official article, let's start with Token:

 namespace MyBundle\Social\Authentication; use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; class Token extends AbstractToken { //      . public $social; public $hash; public $add; //   ,    function __construct(array $roles = array()) { parent::__construct($roles); //    ,    //          . parent::setAuthenticated(count($roles) > 0); } // ,    TokenInterface public function getCredentials() { } //         , //      .     “” //     . public function serialize() { $pser = parent::serialize(); return serialize(array($this->social, $this->hash, $this->add, $pser)); } public function unserialize($serialized) { list($this->social, $this->hash, $this->add, $pser) = unserialize($serialized); parent::unserialize($pser); } } 


The token class, as in the case of the user class, is very simple. Next, we need a Listener, which will create the Token's of the class just described.

 namespace MyBundle\Social\Authentication; //       AbstractAuthenticationListener, //     “”   , //       use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\Security\Http\Firewall\ListenerInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\SecurityContextInterface; use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Http\Firewall\AbstractAuthenticationListener; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; use Symfony\Component\Security\Http\HttpUtils; use Symfony\Component\HttpKernel\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\Extension\Csrf\CsrfProvider\CsrfProviderInterface; class AuthenticationListener extends AbstractAuthenticationListener { //      “”  .  protected $social; //    ,  //   public function __construct(SecurityContextInterface $securityContext, AuthenticationManagerInterface $authenticationManager, SessionAuthenticationStrategyInterface $sessionStrategy, HttpUtils $httpUtils, $providerKey, array $options = array(), AuthenticationSuccessHandlerInterface $successHandler = null, AuthenticationFailureHandlerInterface $failureHandler = null, LoggerInterface $logger = null, EventDispatcherInterface $dispatcher = null, array $social = array()) { parent::__construct($securityContext, $authenticationManager, $sessionStrategy, $httpUtils, $providerKey, array_merge(array( 'intention' => 'authenticate', ), $options), $successHandler, $failureHandler, $logger, $dispatcher); $this->social = $social; } //  attemptAuthentication   ,   //    ,    Token' public function attemptAuthentication(Request $request) { //      ,     //     if ($request->get('uid') && $request->get('hash') && $request->cookies->get("vk_app_{$this->social['vk']['id']}")) { $this->logger->debug("vk auth handled"); //     $uid = $request->get('uid'); $fn = $request->get('first_name'); $ln = $request->get('last_name'); $hash = $request->get('hash'); $this->logger->info("user $fn $ln [$uid] // $hash"); $avatars = array( 'sav' => $request->get('photo'), 'srav' => $request->get('photo_rec'), ); //   token     //   .  … $token = new Token(); $token->setUser("vk{$uid}"); // …     token' ... $token->social = 'vk'; $token->hash = $hash; $token->add = array( 'uid' => $uid, 'avatar' => $avatars, 'name' => "$fn $ln", ); // …     ,     return $this->authenticationManager->authenticate($token); } //        -   . } } 


In essence, the role of a listener is to extract from the request the data necessary to authenticate the user. AbstractAuthenticationListener, gives us the opportunity to restrict ourselves to writing only the code for checking and parsing the request, providing ready-made functionality for filtering requests by URI, managing redirections, and other routines, also part of the AuthenticationListener “duty”.

After implementing the Listenr, we will need an AuthenticationProvider, which will directly check Token and “announce” the successful authentication.

 namespace MyBundle\Social\Authentication; use Mh\Mongo\MongoBundle\ConnectionManager; use MyBundle\Models\User as User; use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\NonceExpiredException; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; use Symfony\Component\HttpKernel\Log\LoggerInterface; //    AuthenticationProvider'     // ,     UserAuthenticationProvider, //            . class Provider implements AuthenticationProviderInterface { protected $userProvider; protected $logger; //     .  protected $social; //       MongoDB  //    protected $mongo; //   ,     //  . public function __construct(UserProviderInterface $userProvider, array $social, ConnectionManager $cm, LoggerInterface $logger) { $this->userProvider = $userProvider; $this->social = $social; $this->mongo = $cm; $this->logger = $logger; } //    public function authenticate(TokenInterface $token) { $user = null; //      UserProvider'a //      ,      try { $user = $this->userProvider->loadUserByUsername($token->getUsername()); } catch (UsernameNotFoundException $ex) { $this->logger->debug("user ".$token->getUsername()." not yet registred"); } try { //    hash' if ($this->checkHash($token)) { $this->logger->info("hash is valid"); //  ,    hash,   //    -  “” if (!$user) { $this->logger->info("register new user"); $user = new User(array( 'uname' => $token->getUsername(), 'social' => $token->social, 'fullname' => $token->add['name'], 'avatar' => $token->add['avatar'], 'suid' => $token->add['uid'], 'roles' => array('ROLE_EXTUSER', strtoupper($token->social) ), )); $user->save($this->mongo); } //    . ,   Token //      $authenticatedToken = new Token($user->getRoles()); $authenticatedToken->social = $token->social; $authenticatedToken->hash = $token->hash; $authenticatedToken->add = $token->add; $authenticatedToken->setUser($user); //        return $authenticatedToken; } else { $this->logger->debug("hash is invalid."); } } catch (\Exception $ex) { $this->logger->err("auth internal exception: $ex"); } //   -     -  //  . throw new AuthenticationException('The Social authentication failed.'); } //   hesh',      //  . protected function checkHash(Token $token) { if ($token->social == 'vk') { return ($token->hash === md5( $this->social['vk']['id'] . $token->add['uid'] . $this->social['vk']['key'] )); } return false; } //  ,    token'  provider' public function supports(TokenInterface $token) { return $token instanceof Token; } } 


After completing the work on AuthenticationProvider, it remains only to teach the framework to use the classes we have written. Unlike UserProvider, this is a rather voluminous and not “transparent” part of the work.

Official documentation reports that to use our code with the Symfony security system, you will need to write an implementation of the SecurityFactoryInterface. So do.

 namespace MyBundle\DependencyInjection\Security; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface; //   Symfony   AbstractFactory     . //    ,      ,   //    AuthenticationProvider' class SocialAuthFactory implements SecurityFactoryInterface { //      firewall'    //  .     form_login. protected $options = array( 'check_path' => '/login_check', 'login_path' => '/login', 'use_forward' => false, 'always_use_default_target_path' => false, 'default_target_path' => '/', 'target_path_parameter' => '_target_path', 'use_referer' => false, 'failure_path' => null, 'failure_forward' => false, ); //  ,       firewall  listener  // provider. //      , id firewall'  // ,       . public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) { //      ,     //   . //      “”   // (  )    . //  id    AuthenticationProvider' $providerId = 'security.authentication.provider.social.'.$id; //    -    my.socialauth.prov //  (0)         //   $container ->setDefinition($providerId, new DefinitionDecorator('my.socialauth.prov')) ->replaceArgument(0, new Reference($userProvider)) ; //  listener'    ,    provider' $listenerId = 'security.authentication.listener.social.'.$id; $container ->setDefinition($listenerId, new DefinitionDecorator('my.socialauth.listener')) ->replaceArgument(4, $id) ->replaceArgument(5, array_intersect_key($config, $this->options)); //   id  . return array($providerId, $listenerId, $defaultEntryPoint); } //  ,       //   .      login_form. public function getPosition() { return 'form'; } //      ,      //     . public function getKey() { return 'mysocial'; } //   ,      // AbstractFactory public function addConfiguration(NodeDefinition $node) { $builder = $node->children(); $builder ->scalarNode('provider')->end() ; foreach ($this->options as $name => $default) { if (is_bool($default)) { $builder->booleanNode($name)->defaultValue($default); } else { $builder->scalarNode($name)->defaultValue($default); } } } } 


After creating the Factory, all that remains is to add the configuration.

Let's start with security.yml. There you need to add a link to the configuration file of your factory ...

  security:

     factories:
         - "% kernel.root_dir% / .. / src / MyBundle / Resources / config / socialauth_factory.yml"
 ... 


... next, create and populate the referenced file, the syntax is the same as the usual services.yml syntax.

 services:
     security.authentication.factory.mysocial:
         class: MyBundle \ DependencyInjection \ Security \ Factory \ SocialFactory
         tags:
             - {name: security.listener.factory}


Now it is necessary to supplement the services.yml itself with the services we refer to in our Factory:

  parameters:
   my.users:
     confname: default
     collection: user
     field: uname

   my.social:
     id: __YOUR_APP_ID__
     key: __YOUR_APP_PRIVATE_KEY__

 services:
   my.users.prov:
     class: MyBundle \ Security \ UserProvider
     arguments: [% my.users%, @ mongo.manager, @ monolog.logger]

   # service for the listener we declare as the heir to the AbstractListener service
   # the arguments specified here will be passed to the constructor after the arguments   
   # prescribed in the parent service
   my.socialauth.listener:
     class: MyBundle \ Social \ Authentication \ Listener
     parent: security.authentication.listener.abstract
     arguments: [% my.social%]
    
   # in the service for provider, we leave the first argument without real value.
   # The value will be transferred from the decorator created in Factory.
   my.socialauth.prov:  
     class: MyBundle \ Social \ Authentication \ Provider
     arguments: ['',% my.social%, @ mongo.manager, @ monolog.logger]


The last thing to do is to include our subsystem in the application configuration. To do this, again rule security.yml, and in it, the firewalls block:

  ...   
     firewalls:
         myauth:
             pattern: ^ /
             # add our firewall to the block to the general form_login, 
             # Recall that he has almost the same settings.
             mysocial:  
                 check_path: / login / socialauth
                 login_path: / login / in
             # our classes will work on equal terms with boxed classes and symfony,
             # so that they do not interfere with each other, we will do different check_path
             form_login:
                 check_path: / login / auth
                 login_path: / login / in
	      # we allow to log in 2 ways, the output is for 
             # Both types to the user by standard means.
             logout:
                 path: / login / out
                 target: /
                 invalidate_session: true
             anonymous: ~
 ... 


That's all. By adding a link to the login form template via VKontakte, we will be able to log in using this social. network.

Results


The symfony 2.0 security system from the beginning may seem complicated and confusing. In general, it is really complicated and confusing, but if you compare the security organization in the second and first versions, you can see one trend.

The security system is organized in such a way that typical tasks can be solved at the framework configuration level. We do not need to program to verify the login / password (except for drawing a form), we don’t even need an action to check. Same for logout. The complexity of the implementation of individual elements is primarily due to the need to embed them in a flexible security architecture.

Thanks for attention.

Source: https://habr.com/ru/post/134005/


All Articles