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:
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
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.
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.
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!