As I said in the title I try to supply the validation context of Sf / Api platform.
More precisely I would like to have different validation groups depending on an entity value.
If i'm a User with ROLE_PRO : then i want validate:pro and
default as validation groups.
If i'm a User with ROLE_USER : then i want default as validation
group.
I tried to create an event based on the following api-platform event but I can't find a way to supply the ExecutionContextInterface with my validation groups
public static function getSubscribedEvents()
{
return [
KernelEvents::VIEW => ['addGroups', EventPriorities::PRE_VALIDATE],
];
}
As you can see in api-platform documentation (https://api-platform.com/docs/core/serialization/#changing-the-serialization-context-dynamically) you can manipulate validation groups dynamically with a service.
First of all, in your api-platform configuration, you have to define default validation group:
App\Class\MyClass:
properties:
id:
identifier: true
attributes:
input: false
normalization_context:
groups: ['default']
You need to define a new service which implements SerializerContextBuilderInterface
class ContextBuilder implements SerializerContextBuilderInterface
{
private SerializerContextBuilderInterface $decorated;
private AuthorizationCheckerInterface $authorizationChecker;
public function __construct(SerializerContextBuilderInterface $decorated, AuthorizationCheckerInterface $authorizationChecker)
{
$this->decorated = $decorated;
$this->authorizationChecker = $authorizationChecker;
}
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
if (isset($context['groups']) && $this->authorizationChecker->isGranted('ROLE_PRO') && true === $normalization) {
$context['groups'][] = 'validate:pro';
}
return $context;
}
}
Also, you need to configure your new service with a decorator
App\Builder\ContextBuilder:
decorates: 'api_platform.serializer.context_builder'
arguments: [ '#App\Builder\ContextBuilder.inner' ]
What it's happening here is:
You're overriding the ContextBuilder. First of all you create the context from request and from configuration (first line of createFromRequest method) and after this, you modify the context depeding of which user is logged.
Thanks!
I try to save initial value for user field in UserService entity. The reason is, I use this entity in EasyAdminBundle and when I build a form, I want to set a default value for user_id (ManyToOne to User entity).
init entity manager in constructor,
I override save method.
I get user from security session context and set to user service object, persist and flush.
...but I still can't see a change during save.
class UserServiceRepository extends ServiceEntityRepository
{
protected $_em;
public function __construct(RegistryInterface $registry)
{
$this->_em = $this->entityManager;
parent::__construct($registry, UserService::class);
}
// I override save method:
public function save(UserService $userService)
{
// Get current user from security:
$user = $this->get('security.token_storage')->getToken()->getUser();
// set to useService...
$userService->setUser($user);
// and persist & flush:
$this->_em->persist($userService);
$this->_em->flush();
}
// I override save method:
You're overriding non-existent method in parent, there's no save method in ServiceEntityRepository nor EntityRepository. So what's the main point of what you are doing and why you're setting default user_id in service repository?
UPDATE:
services:
my.listener:
class: UserServiceListener
arguments:
- "#security.token_storage"
tags:
- { name: doctrine.event_listener, event: prePersist }
Listener:
class UserServiceListener
{
private $token_storage;
public function __construct(TokenStorageInterface $token_storage)
{
$this->token_storage = $token_storage;
}
public function prePersist(LifeCycleEventArgs $args)
{
$entity = $args->getEntity();
if (!$entity instanceof UserService) {
return;
}
$entity->setUser($this->token_storage->getToken()->getUser());
}
}
Actually, I have a listener on AuthenticationEvents::AUTHENTICATION_FAILURE that stores failedLogin in Redis Cache, like:
[
'ip' => [
'xxx.xxx.xxx.xxx' => [
'nbAttempts' => 5,
'lastAttempd' => \DateTime
],
],
'username' => [
'my_login' => [
'nbAttempts' => 3,
'lastAttempd' => \DateTime
],
'my_other_login' => [
'nbAttempts' => 2,
'lastAttempd' => \DateTime
],
]
]
But now, I need to use this list of fails to prevent logins when a user try to connect with a username tries more than x times in n minutes, and the same for an IP (with an other ratio). (later, maybe add a ReCaptcha before block)
To do it, I need to add a custom validation rules on the login. I've found it in the documentation:
http://symfony.com/doc/current/security/custom_password_authenticator.html
https://symfony.com/doc/current/security/guard_authentication.html
But, in both documents, I need to rewrite a lot of things, but I want to keep all the actual behaviors: redirect user on previous page (with referer or on a default page), remember me (in the gurad, I'me forced to return a response on success, else remember me don't work, but I don't really know which response return.... Because if I return null, the redirection work well), messages, etc...
I've search but not found the guard used per default by Symfony to copy/paste it, and just add one rule.
Someone know an other manner, that just consist to rewrite the checkCredential ?
Thanks a lot
EDIT (see the answer at the end):
I've found an advanced guard abstract class: Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator.Then, the authentication work like in Symfony, now, I just need to add my own test in checkCredentials (in my case in the getUser(), I prefer return the error before retrieve the user.
You can listen on the event for failed login attempts. Create a service:
services:
app.failed_login_listener:
class: AppBundle\EventListener\AuthenticationFailureListener
tags:
- { name: kernel.event_listener, event: security.authentication.failure, method: onAuthenticationFailure }
Then create the listener:
<?php
namespace App\EventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
class AuthenticationFailureListener implements AuthenticationFailureHandlerInterface
{
public function onAuthenticationFailure(
Request $request,
AuthenticationException $exception
) {
// do whatever
}
}
Modify your service definition to inject whatever other services you may need.
If you want to perform actions after the user logs in, you can do that with the security.interactive_login event. Just throw exceptions if you encounter situations where you want the void the user's login, and perhaps remove their security token or whatever else you need. You could even do this in your Controller's login action.
For example:
services:
app.security_listener:
class: AppBundle\EventListener\InteractiveLoginListener
tags:
- { name: kernel.event_listener, event: security.interactive_login, method: onInteractiveLogin }
Then have your listener:
<?php
namespace App\EventListener;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
class InteractiveLoginListener
{
public function onInteractiveLogin(InteractiveLoginEvent $event)
{
// do whatever
}
}
Again inject dependencies as needed. Also look at Symfony's creating a custom authentication provider documentation.
Finally, I've found a simply way to do by extending this abstract class: Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator. This Authenticator replace the default FormLoginAuthenticator used by Symfony, but is very simple, and we just rewrite few methods.
Maybe just found a way to get config.yml value, to define routes (avoid to write it in this file, because we declare it in config).
My service declaration:
app.security.form_login_authenticator:
class: AppBundle\Security\FormLoginAuthenticator
arguments: ["#router", "#security.password_encoder", "#app.login_brute_force"]
My FormLoginAuthenticator:
<?php
namespace AppBundle\Security;
use AppBundle\Utils\LoginBruteForce;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Router;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
class FormLoginAuthenticator extends AbstractFormLoginAuthenticator
{
private $router;
private $encoder;
private $loginBruteForce;
public function __construct(Router $router, UserPasswordEncoderInterface $encoder, LoginBruteForce $loginBruteForce)
{
$this->router = $router;
$this->encoder = $encoder;
$this->loginBruteForce = $loginBruteForce;
}
protected function getLoginUrl()
{
return $this->router->generate('login');
}
protected function getDefaultSuccessRedirectUrl()
{
return $this->router->generate('homepage');
}
public function getCredentials(Request $request)
{
if ($request->request->has('_username')) {
return [
'username' => $request->request->get('_username'),
'password' => $request->request->get('_password'),
];
}
return;
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
$username = $credentials['username'];
// Check if the asked username is under bruteforce attack, or if client process to a bruteforce attack
$this->loginBruteForce->isBruteForce($username);
// Catch the UserNotFound execption, to avoid gie informations about users in database
try {
$user = $userProvider->loadUserByUsername($username);
} catch (UsernameNotFoundException $e) {
throw new AuthenticationException('Bad credentials.');
}
return $user;
}
public function checkCredentials($credentials, UserInterface $user)
{
// check credentials - e.g. make sure the password is valid
$passwordValid = $this->encoder->isPasswordValid($user, $credentials['password']);
if (!$passwordValid) {
throw new AuthenticationException('Bad credentials.');
}
return true;
}
}
And, if it's interesting someone, my LoginBruteForce:
<?php
namespace AppBundle\Utils;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Event\AuthenticationFailureEvent;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class LoginBruteForce
{
// Define constants used to define how many tries we allow per IP and login
// Here: 20/10 mins (IP); 5/10 mins (username)
const MAX_IP_ATTEMPTS = 20;
const MAX_USERNAME_ATTEMPTS = 5;
const TIME_RANGE = 10; // In minutes
private $cacheAdapter;
private $requestStack;
public function __construct(AdapterInterface $cacheAdapter, RequestStack $requestStack)
{
$this->cacheAdapter = $cacheAdapter;
$this->requestStack = $requestStack;
}
private function getFailedLogins()
{
$failedLoginsItem = $this->cacheAdapter->getItem('failedLogins');
$failedLogins = $failedLoginsItem->get();
// If the failedLogins is not an array, contruct it
if (!is_array($failedLogins)) {
$failedLogins = [
'ip' => [],
'username' => [],
];
}
return $failedLogins;
}
private function saveFailedLogins($failedLogins)
{
$failedLoginsItem = $this->cacheAdapter->getItem('failedLogins');
$failedLoginsItem->set($failedLogins);
$this->cacheAdapter->save($failedLoginsItem);
}
private function cleanFailedLogins($failedLogins, $save = true)
{
$actualTime = new \DateTime('now');
foreach ($failedLogins as &$failedLoginsCategory) {
foreach ($failedLoginsCategory as $key => $failedLogin) {
$lastAttempt = clone $failedLogin['lastAttempt'];
$lastAttempt = $lastAttempt->modify('+'.self::TIME_RANGE.' minute');
// If the datetime difference is greatest than 15 mins, delete entry
if ($lastAttempt <= $actualTime) {
unset($failedLoginsCategory[$key]);
}
}
}
if ($save) {
$this->saveFailedLogins($failedLogins);
}
return $failedLogins;
}
public function addFailedLogin(AuthenticationFailureEvent $event)
{
$clientIp = $this->requestStack->getMasterRequest()->getClientIp();
$username = $event->getAuthenticationToken()->getCredentials()['username'];
$failedLogins = $this->getFailedLogins();
// Add clientIP
if (array_key_exists($clientIp, $failedLogins['ip'])) {
$failedLogins['ip'][$clientIp]['nbAttempts'] += 1;
$failedLogins['ip'][$clientIp]['lastAttempt'] = new \DateTime('now');
} else {
$failedLogins['ip'][$clientIp]['nbAttempts'] = 1;
$failedLogins['ip'][$clientIp]['lastAttempt'] = new \DateTime('now');
}
// Add username
if (array_key_exists($username, $failedLogins['username'])) {
$failedLogins['username'][$username]['nbAttempts'] += 1;
$failedLogins['username'][$username]['lastAttempt'] = new \DateTime('now');
} else {
$failedLogins['username'][$username]['nbAttempts'] = 1;
$failedLogins['username'][$username]['lastAttempt'] = new \DateTime('now');
}
$this->saveFailedLogins($failedLogins);
}
// This function can be use, when the user reset his password, or when he is successfully logged
public function resetUsername($username)
{
$failedLogins = $this->getFailedLogins();
if (array_key_exists($username, $failedLogins['username'])) {
unset($failedLogins['username'][$username]);
$this->saveFailedLogins($failedLogins);
}
}
public function isBruteForce($username)
{
$failedLogins = $this->getFailedLogins();
$failedLogins = $this->cleanFailedLogins($failedLogins, true);
$clientIp = $this->requestStack->getMasterRequest()->getClientIp();
// If the IP is in the list
if (array_key_exists($clientIp, $failedLogins['ip'])) {
if ($failedLogins['ip'][$clientIp]['nbAttempts'] >= self::MAX_IP_ATTEMPTS) {
throw new AuthenticationException('Too many login attempts. Please try again in '.self::TIME_RANGE.' minutes.');
}
}
// If the username is in the list
if (array_key_exists($username, $failedLogins['username'])) {
if ($failedLogins['username'][$username]['nbAttempts'] >= self::MAX_USERNAME_ATTEMPTS) {
throw new AuthenticationException('Maximum number of login attempts exceeded for user: "'.$username.'". Please try again in '.self::TIME_RANGE.' minutes.');
}
}
return;
}
}
The Voter seems to work on my whole app... except on this controller:
$entity = $em->getReference('AppBundle:Offer',$id);
$this->denyAccessUnlessGranted('overview', $entity);
Where this Voter method is receiving wrong arguments ....
supports($attribute, $subject)
dump($attribute)-> ROLE_USER // instead 'overview'
dump($subject)-> Request Object // instead $entity
The Voter config is:
app_voter:
class: AppBundle\Security\Authorization\AppVoter
public: true
strategy: affirmative
arguments: ['#role_hierarchy', '#security.token_storage']
tags:
- { name: security.voter }
The problem disappears if instead 'overview' I write 'view' on the controller code.
I forgot to add 'overview' to the method 'supports' :
protected function supports($attribute, $subject) {
// if the attribute isn't one we support, return false
if (!in_array($attribute, array(self::OVERVIEW, self::VIEW, self::EDIT))) {
return false;
}
// bypass if the entity is not supported
if (!$this->isSupportedClass($subject)) {
return true;
}
return true;
}
I've got little problem.
I've overrided all html exception templates in app/Resources/TwigBundle/Resources/Exception...
My problem is, that these error pages are only rendered when I'm on the dev env.
When it comes to prod I'll get something like:
http://i.stack.imgur.com/GHd7t.png
Please help me out.
You can do it by registering a service listening to the kernel.view event.
in your service.yml:
your.kernel_listener:
class: Your\AppBundle\EventListener\KernelListener
arguments: [#kernel]
tags:
- { name: kernel.event_listener, event: kernel.view, method: onKernelView }
in your class KernelListener:
namespace Your\AppBundle\EventListener;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
use Symfony\Component\HttpFoundation\Response;
class KernelListener
{
private $kernel;
public function __construct(Kernel $kernel)
{
$this->kernel = $kernel;
}
public function onKernelView(GetResponseForControllerResultEvent $event)
{
if ($this->kernel->getEnvironment() == 'dev') {
$result = $event->getControllerResult();
$response = new Response(print_r($result, true), 200, array('Content-Type' => 'text/html'));
$event->setResponse($response);
}
}
}
Have a look at this guide.