Buy

Validation Groups: Conditional Validation

Ready for the problem? Right now, we need the plainPassword to be required. But later when we create an edit profile page, we don't want to make plainPasword required. Remember, this is not saved to the database. So if the user leaves it blank on the edit form, it just means they don't want to change their password. And that should be allowed.

So, we need this annotation to only work on the registration form.

Validation Groups to the Rescue!

Here's how you do it: take advantage of something called validation groups. On the NotBlank constraint, add groups={"Registration"}:

118 lines src/AppBundle/Entity/User.php
... lines 1 - 15
class User implements UserInterface
{
... lines 18 - 38
/**
... line 40
* @Assert\NotBlank(groups={"Registration"})
... lines 42 - 43
*/
private $plainPassword;
... lines 46 - 116
}

This "Registration" is a string I just invented: there's no significance to it.

Without doing anything else, go back, hit register, and check it out! The error went away. Here's what's happening: by default, all constraints live in a group called Default. And when your form is validated, it validates all constraints in this Default group. So now that we've put this into a different group called Registration, when the form validates, it doesn't validate using this constraint.

To use this annotation only on the registration form, we need to make that form validate everything in the Default group and the Registration group. Open up UserRegistrationForm and add a second option to setDefaults(): validation_groups set to Default - with a capital D and then Registration:

32 lines src/AppBundle/Form/UserRegistrationForm.php
... lines 1 - 12
class UserRegistrationForm extends AbstractType
{
... lines 15 - 23
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
... line 27
'validation_groups' => ['Default', 'Registration']
]);
}
}

That should do it. Refresh: validation is back.

Ok team: one final mission: automatically authenticate the user after registration. Because really, that's what our users want.

Leave a comment!

  • 2017-08-17 Mike P

    Awesome, Iam feeling so dangerous that I've implemented a login constraint of max. 10 failed login attempts.
    I want to share my code for others and ask you: Is this the easiest/best practice way of doing it?

    User.php


    ...
    /**
    * @Assert\NotBlank()
    * @ORM\Column(type="integer")
    */
    private $loginAttempts = 0;
    ...

    LoginFormAuthenticator.php


    use AppBundle\Entity\User;
    ...
    use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
    ...

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
    $username = $credentials['_username'];

    $user = $this->em->getRepository('AppBundle:User')
    ->findOneBy(['email' => $username]);
    // Max. 10 login_attempts
    if ($user instanceof User && $user->getLoginAttempts() >= 10) {
    throw new CustomUserMessageAuthenticationException(
    'Max. 10 login attempts. Please reset your password.'
    );
    }
    return $user;
    }
    ...

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
    // Reset login_attempts to 0
    $user = $token->getUser();
    // If User found +1 failed login attempt
    if ($user instanceof User) {
    $user->setLoginAttempts(0);
    $this->em->persist($user);
    $this->em->flush();
    }
    // if the user hits a secure page and start() was called, this was
    // the URL they were on, and probably where you want to redirect to
    $targetPath = $this->getTargetPath($request->getSession(), $providerKey);
    if (!$targetPath) {
    $targetPath = $this->router->generate('homepage');
    }
    return new RedirectResponse($targetPath);
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
    // Increase failed login attempt
    if ($request->request->get('login_form')['_username']){
    $username = $request->request->get('login_form')['_username'];
    $user = $this->em->getRepository('AppBundle:User')
    ->findOneBy(['email' => $username]);
    // If Username found +1 failed login attempt
    if ($user instanceof User) {
    $user->setLoginAttempts($user->getLoginAttempts()+1);
    $this->em->persist($user);
    $this->em->flush();
    }
    }
    return parent::onAuthenticationFailure($request, $exception); // TODO: Change the autogenerated stub
    }
  • 2017-05-17 Terry Caliendo

    Thanks for popping in weaverryan and for the extra notes on the "official" solution! And the references/links are super helpful to understand what's going on.

    I can't wait to get the chance to dig into the new FOSUserBundle tutorial!

    And thanks for the compliment :)
    tc

  • 2017-05-17 weaverryan

    Yo Terry Caliendo!

    Yep, Guard *is* now part of Symfony (woohoo!). So you're right, that tutorial needs an update (it's super on my list!). A better tutorial to point to is.. well... this one - or the new FOSUserBundle tutorial (https://knpuniversity.com/s....

    Anyways, the issue you hit is a weird spot in Symfony's security, and you understand what's going on well. At the beginning of each request, the User object is deserialized from the session and then "refreshed" (i.e. we re-query the database by using the id of the serializer User). Then, yep, they are compared to see if they are "equal". This reason this is done is for security - e.g. if I change my password on one machine, I would be logged out on a different machine (important if you imagine your account gets hacked). But, it can cause some quirky behavior.

    Ultimately, if you're having issues, the "official" solution is to add an extra interface to your User class: EquatableInterface (https://github.com/symfony/.... With this, Symfony will now call the isEqualTo method at the beginning of the request to see if the user has "changed" (instead of trying its own logic). Btw, you can see all of this in action here: https://github.com/symfony/...

    Phew! Well, I hope that brightens what's going on even a bit more. Nice job digging in - I'm impressed how well you were able to see what was going on in the core :).

    Cheers!

  • 2017-05-16 Terry Caliendo

    Thanks. I've seen it but figured it was old because you have to install Guard. Isn't Guard part of Symfony by default now?

  • 2017-05-16 Diego Aguiar

    I'm glad you managed to fix your problem, that's great!

    FYI, we have a dedicated tutorial for Guard, it might help you to understand in a deeper way how Guard works https://knpuniversity.com/s... It's not finished yet, but hey, it's free! :)

    Cheers!

  • 2017-05-15 Terry Caliendo

    Thanks again for the response Diego.

    I just figured out the issue! There isn't a listener on the User object as I suspected, but the user object gets serialized into the session variables and (I think) verified against the user object upon the next session.

    So I started dumping the session variables at the beginning and end of my controller. They were the same. It took me a while to realize that there isn't a listener on the User object but that the session variable for the user gets updated after my controller ends, which is why I don't see the change.

    Anyway, the fix was to save the original password and put it back into the User object so that when it gets serialized, it doesn't cause issues with Guard. I was also having the same logout issue on username change validation, so its "reset" back to the original as well.

    Here is my updated code:

        /**
    * @Route("/profile", name="admin_profile")
    */
    public function profileAction(Request $request){

    $originalPass = $this->getUser()->getPassword();
    $originalUser = $this->getUser()->getUsername();

    $form = $this->createForm(UserProfile::class, $this->getUser());


    // only handles data on POST request
    $form->handleRequest($request);

    if($form->isValid())
    {
    $user = $form->getData();
    $em = $this->getDoctrine()->getManager();
    $em->persist($user);
    $em->flush();

    $this->addFlash('BodySuccessMessage', "User Profile Successfully Updated");

    return $this->redirectToRoute('admin_profile');
    }

    // Failed Validation
    // - put the username and password back
    // - if user is not trying to change either; this effectively does nothing, which is fine.
    $this->getUser()->setUsername($originalUser);
    $this->getUser()->setPlainPassword(null); // tested to not be needed; I do not know why. It must not be checked upon deserialize
    $this->getUser()->setPassword($originalPass);

    return $this->render('admin/user.profile.html.twig', array(
    'ProfileForm' => $form->createView(),
    ));
    }

  • 2017-05-15 Diego Aguiar

    Hey Terry Caliendo!

    That's why FOSUserBundle shines, it gives you all that functionality for free ;)
    But let's figure it out.

    I think you need to split your Form, one for only updating the password and another one for all the other fields, when you process the PasswordForm (and is valid), you will have to do two things:
    - Encode and update the new password
    - Set to null "plainPassword" field, and persist User object (3 things)

    Then return a RedirectResponse, I hope it would do the trick
    Maybe you should give a look into FOSUserBundle guts, it might help you a bit

    Cheers!

  • 2017-05-12 Terry Caliendo

    Thanks again for the response. Yes, updating other fields that don't have to do with authentication do not cause this subsequent logout issue (address, phone, etc) regardless if they are valid or invalid.

    Unfortunately returning a RedirectResponse isn't helpful because it never calls the controller. The user is redirected to the login page before the controller for the re-submission is even called (to prove it, I put a dump/die statement as the first line to prove its not called).

    I think I've found the issue, but don't know the remediation...

    Here is my controller:

        /**
    * @Route("/profile", name="admin_profile")
    */
    public function profileAction(Request $request){

    $form = $this->createForm(UserProfile::class, $this->getUser());

    // only handles data on POST request
    $form->handleRequest($request);

    if($form->isValid())
    {
    $user = $form->getData();
    $em = $this->getDoctrine()->getManager();
    $em->persist($user);
    $em->flush();

    $this->addFlash('BodySuccessMessage', "User Profile Successfully Updated");

    return $this->redirectToRoute('admin_profile');
    }
    return $this->render('admin/user.profile.html.twig', array(
    'ProfileForm' => $form->createView(),
    ));
    }

    I did some testing and it appears as though any change to the current User object like the following causes the same issue I'm experiencing:

    $user = $this->getUser();
    $user->setPlainPassword('password'); // updated but not persisted

    So making a change to the current $user object must automatically update the security even though its not persisted. So does guard have a listener on updates to the User object? I figured the listener would be on the persistence to the database.

    In my controller code above, I have the $form and subsequently its method handleRequest operating on the current User object.

    $form = $this->createForm(UserProfile::class, $this->getUser());
    // only handles data on POST request
    $form->handleRequest($request);

    Since handleRequest therefor updates the current User object to the incorrect values (as it would any other field) to be sent back to the displayed form with the processed errors, the security must be getting updated when the current User object is updated (per my testing). That would explain why the user is subsequently logged out. But I don't have a deep enough understanding of Symfony to stop that from happening.

    Maybe I shouldn't pass $this->getUser() to the create form:

    $form = $this->createForm(UserProfile::class, $this->getUser());

    But instead I should then pass a new User object:

    $form = $this->createForm(UserProfile::class, new User());

    But then how do I persist that new User object in place of the old User object? It seems ridiculous to have to copy each of the values when the form isValid() returns true:


    if($form->isValid())
    {
    $NewUser = $form->getData();
    $User->setUsername($NewUser->getUsername);
    $User->setAddress($NewUser->getAddress);
    // ... All the other values inbetween
    $User->setPassword($NewUser->getPassword);


    $em = $this->getDoctrine()->getManager();
    $em->persist($user);
    $em->flush();

    $this->addFlash('BodySuccessMessage', "User Profile Successfully Updated");

    return $this->redirectToRoute('admin_profile');
    }

  • 2017-05-12 Diego Aguiar

    Ohhh I got you know, I totally misunderstood you.

    Updating any other field than password works fine ?
    Also, try returning a RedirectResponse Object when submit is valid, I think the problem is that symfony need's to reload the user token.

    I hope it helps you :)

  • 2017-05-12 Terry Caliendo

    Hi Diego Aguiar
    Thanks for your response. Unfortunately looking back I think I was pretty unclear as to what I am fully looking to accomplish. Or maybe I'm not understanding your response.

    Here's what's happening...
    I'm working on the Edit profile page. So the user is logged in and editing their profile. They can update their name, address, password, etc. If the user leaves the password blank, its not updated (as indicated in the video by using validation groups and I just now went back and added the @assert to my code above). However, if the user enters a password, it needs to be checked to verify that it at least has some minimum security (so the user doesn't use say a 1-letter password).

    I am validating other fields and if one of those fields fails validation the form is returned with those error messages as well.

    I DON'T want the user to be logged out if the user puts in a password that doesn't pass the regular expression, but this is what is happening.

    Say the user tries to change his/her password to "dog" which is too short. The user submits the password and gets the error page back that says "Password must be at least 5 chars in length with 1 letter and 1 number". So that's good. But then when the user goes to submit a retry of say "dog55", which meets the criteria, the user is redirected to the login page because of this logout issue I'm having.

    Is that more clear? (I'm sorry if I didn't understand your answer if that's the question you were answering).

    Thanks again!
    Terry

  • 2017-05-11 Diego Aguiar

    Hey Terry Caliendo!

    That's an interesting feature you want to implement. Can the validation fail for any other reasons, like invalid email ?
    if it's only for the password, then you can do something like this:


    if($form->submitted()){
    if($form->isValid(){
    // on valid submit logic
    }
    else {
    // invalid logic
    // logout user
    }
    }

    The easiest way I can think of how to logout an user is by redirecting them to your logout path, and you may want to set a flash message with the reason of why he is now logged out (if you want to do it manually, I'm not sure how, but this might help you http://stackoverflow.com/a/... )

    Now, if you don't want to be logged out inmediately, you will need some sort of a flag that informs the system he must be logout in his next request (maybe in the session), the easiest but not cleanest way would be a listener to "kernel.request" (http://symfony.com/doc/curr...

    Have a nice day!

  • 2017-05-11 Terry Caliendo

    I'm trying to validate the plainPassword with a regular expression. If the user passes validation, the password gets updated and can continue on logged-in for the session. However, if the validation fails, the error is shown on a returned page, but upon the next submission (or clicking any protected link), the user is suddenly logged out and brought to the login page. Any ideas on how to solve the logout issue when the password authentication fails?

        /**
    * @Assert\NotBlank(groups={"Registration"})
    * @Assert\Regex(
    * pattern="/^(?=.*[A-Za-z])(?=.*\d)[[A-Za-z\d$@$!%*#?&]{5,}$/",
    * message="Password must be at least 5 chars in length with 1 letter and 1 number"
    * )
    */
    private $plainPassword;

  • 2016-10-04 Victor Bocharsky

    Hey John,

    Yes, the right way to check the $plainPassword: if $plainPassword is empty - then user does not change his pass, so skip it. But if the $plainPassword is set - encode it and store in $password field. BTW, it's a bit incorrect behavior for registration form, because user have to fill $password during registration. Check out the Symfony Form Validation Groups, it should helps in this case, i.e. require password on registration page and make it optional on edit page.

    Cheers!

  • 2016-10-03 John

    My workaround or fix was to grab the original password ( $origpw = $user->getPassword(); ) then check if form data's 'plainPassword' was empty, and if so, set form->setPassword($origpw); Hopefully, that is the right way to do it!

  • 2016-10-03 John

    I'm not sure if I have a problem, but on my edit user page, I have the same fields as the new user form, so the user can choose to edit the password field or not. Yet when I leave the password fields blank, it resets the password to blank in the database. Is that the correct behavior of the edit form, or am I doing something wrong? Please help :)

  • 2016-07-06 weaverryan

    Ah, thank you! I've fixed that (https://github.com/knpunive... and published it!

    Cheers!

  • 2016-07-06 weaverryan

    Hi Giorgio Pagnoni!

    For this, there are two main pieces

    1) Create a new form class - like EditUserForm. This will allow you to have different fields than your registration form. If many of the fields are shared (between EditUserForm and the RegistrationForm), then, if you want, you can create a common base class and add those common fields in the buildForm method there (then override that method and call parent::buildForm() form the child classes)

    2) Then yes, *if* you have some validation differences, handle those with groups. But like we did here, I don't automatically add everything to groups - I identify the specific constraints that are conditional, and then handle those with groups. For something like a Captcha, it depends on what library you use, but its validation *may* be built right into the form field itself (i.e. http://knpuniversity.com/sc.... In that case, simply *not* having the field will remove its built-in validation.

    Cheers!

  • 2016-07-06 Dominik

    Script typo:
    validation_groups
    set to Defaults - with a capital D and then

    should be: Default (without S)

  • 2016-07-06 Giorgio Pagnoni

    Awesome tutorials, as usual. How would you go about creating an edit form? A form which is almost exactly like our registration form, but where, say, some fields (like email) are read-only or some missing (like a capctha, that might be useful when signing up but not when editing)? Would you, say, create a basic edit form, and then extend it for registration, changing the validation groups?