3
votes

I need help with building custom authentication in Symfony2 project. I've read the symfony cookbook http://symfony.com/doc/2.3/cookbook/security/custom_authentication_provider.html and found many questions about the custom authentication but they didn't answer to my question in situation when I trying to do this with FOS User Bundle. I spent many ours of investigation of symfony authentication process but can't understand where am I wrong.

So what I have now:

  1. FOS User Bundle successfully installed and configured following by the official documentation.
  2. Sonata User Bundle installed.
  3. Custom authentication listener, token, provider, factory added and configured. After that I normally log in but as I see my new authentication provider is not used and the user is logged in with FOS's "form_login".

Here is my code:

User entity class:

<?php
    namespace Acme\UserBundle\Entity;

    use Sonata\UserBundle\Entity\BaseUser as BaseUser;
    use Doctrine\ORM\Mapping as ORM;
    use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
    use \Acme\BoardBundle\Entity\Card;

    /**
     * @ORM\Entity
     * @ORM\HasLifecycleCallbacks
     * @ORM\Table(name="fos_user")
     */
    class User extends BaseUser
    {
        ...

        protected $card;

        /**
         * Set card
         *
         * @param \Acme\BoardBundle\Entity\Card $card
         * @return Card
         */
        public function setCard(\Acme\BoardBundle\Entity\Card $card)
        {
            $this->card = $card;

            return $this;
        }

        /**
         * Get card
         *
         * @return \Acme\BoardBundle\Entity\Card
         */
        public function getCard()
        {
            return $this->card;
        }
    }

User.orm.xml:

<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
                  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                  xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
                  http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">

    <entity name="Acme\UserBundle\Entity\User" table="fos_user">

        ...

        <many-to-one field="card" target-entity="Acme\BoardBundle\Entity\Card" inversed-by="users">
            <join-column name="card" referenced-column-name="id" />
        </many-to-one>
    </entity>
</doctrine-mapping>

The User entity has a relation with Card entity which has two properties: card number and PIN code. And the properties I actually need to check after login. My login form has not only username and password fields but also the card number and PIN fields.

security.yml (where I feel I have some errors in firewall configuration but I can't understand what's wrong):

providers:
    fos_userbundle:
        id: fos_user.user_manager
firewalls:
    dev:
        pattern:  ^/(_(profiler|wdt)|css|images|js)/
        security: false
    admin:
        pattern:            /admin(.*)
        context:            user
        form_login:
            provider:       fos_userbundle
            login_path:     /admin/login
            use_forward:    false
            check_path:     /admin/login_check
            failure_path:   null
        logout:
            path:           /admin/logout
        anonymous:          true
    main:
        pattern:             .*
        context:             user
        acme: true

        form_login:
            provider:       fos_userbundle
            login_path:     /user/login
            use_forward:    false
            check_path:     /user/login_check
            failure_path:   null
            always_use_default_target_path: true
            default_target_path:            ad_category
        logout:
            path:           /user/logout
        anonymous:          true

User Token:

<?php

namespace Acme\UserBundle\Security\Authentication\Token;

use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;

class AcmeUserToken extends AbstractToken
{
    public $userFIO;
    public $cardNumber;
    public $cardPIN;

    public function __construct(array $roles = array())
    {
        parent::__construct($roles);

        // If the user has roles, consider it authenticated
        $this->setAuthenticated(count($roles) > 0);
    }

    public function getCredentials()
    {
        return '';
    }

    // поскольку токены проверяются при обработке каждом новом запросе клиента, 
    // нам необходимо сохранять нужные нам данные. В связи с этим “обертываем”  
    // унаследованные методы сериализации и десериализации.
    public function serialize() {        
        $pser = parent::serialize();        
        //return serialize(array($this->social, $this->hash, $this->add, $pser));
        return serialize(array($pser));
    }

    public function unserialize($serialized) {
        //list($this->social, $this->hash, $this->add, $pser) = unserialize($serialized);        
        list($pser) = unserialize($serialized);
        parent::unserialize($pser);        
    }
}

AcmeProvider.php (my custom authentication provider):

<?php

namespace Acme\UserBundle\Security\Authentication\Provider;

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 Acme\UserBundle\Security\Authentication\Token\AcmeUserToken;

class AcmeProvider implements AuthenticationProviderInterface
{
    private $userProvider;

    public function __construct(UserProviderInterface $userProvider)
    {
        $this->userProvider = $userProvider;
    }

    public function authenticate(TokenInterface $token)
    {
        $user = $this->userProvider->loadUserByUsername($token->getUsername());

        if ($user) {
            $authenticatedToken = new AcmeUserToken($user->getRoles());
            $authenticatedToken->setUser($user);

            return $authenticatedToken;
        }

        throw new AuthenticationException('The Acme authentication failed.');
    }

    public function supports(TokenInterface $token)
    {
        return $token instanceof AcmeUserToken;
    }
}

Factory class AcmeFactory.php:

<?php
namespace Acme\UserBundle\DependencyInjection\Security\Factory;

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;

class AcmeFactory implements SecurityFactoryInterface
{
    public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint)
    {
        $providerId = 'security.authentication.provider.acme.'.$id;
        $container
        ->setDefinition($providerId, new DefinitionDecorator('acme.security.authentication.provider'))
        ->replaceArgument(0, new Reference($userProvider))
        ;

        $listenerId = 'security.authentication.listener.acme.'.$id;
        $listener = $container->setDefinition($listenerId, new DefinitionDecorator('acme.security.authentication.listener'));

        return array($providerId, $listenerId, $defaultEntryPoint);
    }

    public function getPosition()
    {
        //return 'pre_auth';
        return 'form';
    }

    public function getKey()
    {
        return 'acme';
    }

    public function addConfiguration(NodeDefinition $node)
    {
    }
}

Configuration of the user provider and listner in config.yml:

services:
    acme.security.authentication.provider:
        class: Acme\UserBundle\Security\Authentication\Provider\AcmeProvider
        abstract:  true
        arguments: ['']
        public: false

    security.authentication.listener.abstract:
        tags:
            - { name: 'monolog.logger', channel: 'security' }
        arguments: [@security.context, @security.authentication.manager, @security.authentication.session_strategy, @security.http_utils, "knetik",@security.authentication.success_handler, @security.authentication.failure_handler, {}, @logger, @event_dispatcher]
        class: Symfony\Component\Security\Http\Firewall\AbstractAuthenticationListener
     # override application level success handler and re-route back
    security.authentication.success_handler:
        class:  Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler
        arguments:  ["@security.http_utils", {}]
        tags:
            - { name: 'monolog.logger', channel: 'security' }
    # override application level failure handler and re-route back
    security.authentication.failure_handler:
        class:  Symfony\Component\Security\Http\Authentication\DefaultAuthenticationFailureHandler
        arguments:  ["@http_kernel", "@security.http_utils", {}, "@logger"]
        tags:
            - { name: 'monolog.logger', channel: 'security' }
    yamogu.security.authentication.listener:
        class: Acme\UserBundle\Security\Authentication\Firewall\AcmeListener
        parent: security.authentication.listener.abstract
        abstract:  true
        arguments: ["@security.context", "@security.authentication.manager"]
        public: false

If you need an additinal code I'll add it to the question. Any help will be appreciated!

Link on the dev.log after authorization: https://www.dropbox.com/s/5uot2qofmqjwvmk/dev.log?dl=0

2

2 Answers

3
votes

I've found a solution of my problem but I went another way. I've defined a success authentication handler and failure handler for form_login and put my logic here. I manually register a user in failure handler if he inputs wrong username but right card number and pin. And viсe versa if a user inputs right username but wrong card number and pin then I reject his login in success authentication failure and manually log out him.

See my source code

Peace of security.yml:

security:
    firewalls:
        ...
        main:
            pattern:             .*
            context:             user

            form_login:
                provider:       fos_userbundle
                login_path:     /user/login
                use_forward:    false
                check_path:     /user/login_check
                failure_path:   null
                always_use_default_target_path: true
                default_target_path:            ad_category
                success_handler: authentication_success_handler
                failure_handler: authentication_failure_handler
            logout:
                path:           /user/logout
            anonymous:          true

config.yml:

services:
    authentication_success_handler:
        class: Yamogu\UserBundle\Handler\AuthenticationSuccessHandler
        arguments: [@router, @doctrine.orm.entity_manager, @security.context]
    authentication_failure_handler:
        class: Yamogu\UserBundle\Handler\AuthenticationFailureHandler
        arguments: [@router, @doctrine.orm.entity_manager, @security.context, @event_dispatcher]

AuthenticationSuccessHandler.php:

namespace Acme\UserBundle\Handler;

Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Router;
use Doctrine\Common\Persistence\ObjectManager;
use Acme\BoardBundle\Entity\Card;
use Symfony\Component\Security\Core\SecurityContext;

class AuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
{
    protected $router;

    private $om;

    private $securityContext;

    public function __construct(Router $router, ObjectManager $om, SecurityContext $securityContext)
    {
        $this->router = $router;
        $this->om = $om;
        $this->securityContext = $securityContext;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token)
    {
        $fosUser = $this->securityContext->getToken()->getUser();
        if($fosUser->getCard())
        {
            $card = $fosUser->getCard()->getNumber();
            $pin = $fosUser->getCard()->getPin();
            if($card == $request->get('card') && $pin == $request->get('pin'))
            { //  if Log out the user he inputs wrong card
                $loginName = $request->get('firstname');
                $fosUserFirstName = $fosUser->getFirstname();
                if($loginName && $loginName != $fosUserFirstName)
                {
                    $fosUser->setFirstname($loginName);
                    $this->om->flush();
                }
                return new RedirectResponse($this->router->generate("ad_category"));
            }
        }
        $this->securityContext->setToken(null);
        $request->getSession()->invalidate();
        $request->getSession()->getFlashBag()->set('acme_login_error', 'Error!');
        return new RedirectResponse($this->router->generate("fos_user_security_login"));
    }
}
?>

AuthenticationFailureHandler.php:

<?php
namespace Acme\UserBundle\Handler;

use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Router;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Security\Core\SecurityContext;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Acme\BoardBundle\Entity\Card;
use Acme\UserBundle\Entity\User as YamUser;

class AuthenticationFailureHandler implements AuthenticationFailureHandlerInterface
{
    protected $router;
    private $om;
    private $securityContext;
    private $eventDispatcher;

    public function __construct(Router $router, ObjectManager $om, SecurityContext $securityContext, EventDispatcher $eventDispatcher)
    {
        $this->router = $router;
        $this->om = $om;
        $this->securityContext = $securityContext;
        $this->eventDispatcher = $eventDispatcher;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        if($request->get('firstname') !== null && $request->get('_username') && $request->get('_password') !== null && $request->get('card') !== null && $request->get('pin') !== null)
        {
            $loginName = $request->get('firstname');
            $username = $request->get('_username');
            $passw = $request->get('_password');
            $loginCard = $request->get('card');
            $loginPin = $request->get('pin');
            $card = $this->om->getRepository('AcmeBoardBundle:Card')
                ->findOneBy(array("number" => $loginCard, "pin" => $loginPin));
            // If there is the requested card in the DB create a new user and log in him at the moment
            if($card)
            { // Create a new user for this card, log in him and redirect to the board
                $entity = new YamUser();
                $entity->setCard($card);
                $entity->setFirstname($loginName);
                $entity->setUsername($username);
                $entity->setPlainPassword($passw);
                $entity->setEmail($username);
                $entity->setEnabled(true);
                $this->om->persist($entity);
                $this->om->flush();

                $token = new UsernamePasswordToken($entity, null, "main", $entity->getRoles());
                $this->securityContext->setToken($token); //now the user is logged in
                //now dispatch the login event
                $event = new InteractiveLoginEvent($request, $token);
                $this->eventDispatcher->dispatch("security.interactive_login", $event);
                return new RedirectResponse($this->router->generate("ad_category"));
            }
        }
        $this->securityContext->setToken(null);
        $request->getSession()->invalidate();
        $request->getSession()->getFlashBag()->set('acme_login_error', 'Error!');
        return new RedirectResponse($this->router->generate("fos_user_security_login"));
    }
}
?>

As I can see this is not the best way to solve the task but it's worked for me. If anyone has better solution or fixes for my solution please add them here!

0
votes

You have to let your security context know about your factory in your bundle class. In your bundle class do this:

class UserBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        parent::build($container);
        $extension = $container->getExtension('security');
        $extension->addSecurityListenerFactory(new AcmeFactory());
    }
    public function getParent()
    {
        return 'FOSUserBundle';
    }
}

[Edit]
Security layer in Symfony is very difficult to understand!. I suggest you to follow this blog post to gain an understanding of Symfony security.