Caution: You are browsing the legacy 1.x part of this website.
This version of symfony is not maintained anymore. If some of your projects still use this version, consider upgrading.
This work is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.

Master Symfony2 fundamentals

Be trained by SensioLabs experts (2 to 6 day sessions -- French or English).
trainings.sensiolabs.com

Discover the SensioLabs Support

Access to the SensioLabs Competency Center for an exclusive and tailor-made support on Symfony
sensiolabs.com

Day 06: security and form validation

Previously on symfony

During the fifth day, you got used to manipulating templates and actions; forms and pagers have no secrets for you anymore. But after building the login form, you probably expected us to show you how to restrict access to non-authorised users for a specific set of functionalities. That's what we are going to do today, together with some form validation. As we will extend the application with custom classes, you should be comfortable with the concepts exposed in the custom extension chapter of the symfony book.

Login form validation

Validation file

The login form has a nickname and a password field. But what will happen if a user submits incorrect data? To be able to handle this case, create a login.yml file in the /frontend/modules/user/validate directory (login is the name of the action to validate). Add the following content:

methods:
  post: [nickname, password]

names:
  nickname:
    required:     true
    required_msg: your nickname is required
    validators:   nicknameValidator

  password:
    required:     true
    required_msg: your password is required

nicknameValidator:
    class:        sfStringValidator
    param:
      min:        5
      min_error:  nickname must be 5 or more characters

First, under the methods header, the list of fields to be validated is defined for the methods of the form (we only define POST method here because the GET is to display the login form and does not need validation). Then, under the names header, the requirements for each of the fields to be checked are listed, along with the corresponding error message. Eventually, as the 'nickname' field is declared to have a specific set of validation rules, they are detailed under the corresponding header. In this example, the sfStringValidator is a symfony built-in validator that checks the format of a string (the default symfony validators are exposed in the how to validate a form of the symfony book).

Error handling

So what is supposed to happen if a user enters wrong data? The conditions written in the login.yml file will not be met, and the symfony controller will pass the request to the handleErrorLogin() method of the userActions class - instead of the executeLogin() method, as planned in the form_tag argument. If this method doesn't exist, the default behaviour is to display the loginError.php template. That's because the default handleError() method returns:

public function handleError()
{
  return sfView::ERROR;
}

That's a whole new template to write. But we'd rather display the login form again, with the error messages displayed close to the problematic fields. So let's modify the login error behaviour to display, in this case, the loginSuccess.php template:

public function handleErrorLogin()
{
  return sfView::SUCCESS;
}

note

The naming conventions that link the action name, its return value and the template file name are exposed in the view chapter of the symfony book.

Template error helpers

Once the loginSuccess.php template is called again, it is time to display the errors. We will use the form_error() helper of the Validation helper group for that purpose. Change the two form-row divs of the template to:

<?php use_helper('Validation') ?>
 
<div class="form-row">
  <?php echo form_error('nickname') ?>
  <label for="nickname">nickname:</label>
  <?php echo input_tag('nickname', $sf_params->get('nickname')) ?>
</div>
 
<div class="form-row">
  <?php echo form_error('password') ?>
  <label for="password">password:</label>
  <?php echo input_password_tag('password') ?>
</div>

The form_error() helper will output the error message defined in the login.yml if an error is declared in the field given as a parameter.

It is time to test the form validation by trying to enter a nickname of less than 5 characters, or by omitting one the two fields. The error messages magically display above the concerned fields:

error in the login form

The password is now compulsory, but there is no password in the database! That doesn't matter, as soon as you enter any password, the login will be successful. That's not a very secure process, is it?

Style errors

If you tested the form and got an error, you probably noticed that your errors are not styled the same way as the ones of the capture above. That's because we defined the styling of the .form_error class (in web/main.css), which is the default class of the form errors generated by the form_error() helper:

.form_error
{
  padding-left: 85px;
  color: #d8732f;
}

Authenticate a user

Custom validator

Do you remember yesterday's check about the existence of an entered nickname in the login action? Well, that sounds like a form validation. This code should be taken out from the action and included into a custom validator. You think it is complicated? It really isn't. Edit the login.yml validation file as follows:

...
names:
  nickname:
    required:      true
    required_msg:  your nickname is required
    validators:    [nicknameValidator, userValidator]
...
userValidator:
    class:         myLoginValidator
    param:
      password:    password
      login_error: this account does not exist or you entered a wrong password

We just added a new validator for the nickname field, of class myLoginValidator. This validator doesn't exist yet, but we know that it will need the password to fully authenticate the user, so it is passed as a parameter with the label password.

Password storage

But wait a minute. In our data model, as well as in the test data, there is no password set. It is time to define one. But you know that storing a password in clear text, in a database, is a bad idea for security reasons. So we will store a sha1 hash of the password as well as the random key used to hash it. If you are not familiar with this 'salt' process, check out the password cracking practices.

So open the schema.xml and add the following columns to the User table:

<column name="email" type="varchar" size="100" />
<column name="sha1_password" type="varchar" size="40" />
<column name="salt" type="varchar" size="32" />

Rebuild the Propel model by a symfony propel-build-model. You should also add the two columns to the database, either manually or by using the lib.model.schema.sql generated after a symfony propel-build-sql. Now open the askeet/lib/model/User.php and add this setPassword() method:

public function setPassword($password)
{
  $salt = md5(rand(100000, 999999).$this->getNickname().$this->getEmail());
  $this->setSalt($salt);
  $this->setSha1Password(sha1($salt.$password));
}

This function simulates a direct password storage, but instead it stores the salt random key (a 32 characters hashed random string) and the hashed password (a 40 characters string).

Add password in the test data

Remember the day three test data file? It is time to add a password and an email to the test users. Open and modify the askeet/data/fixtures/test_data.yml as follows:

User:
  ...
  fabien:
    nickname:   fabpot
    first_name: Fabien
    last_name:  Potencier
    password:   symfony
    email:      fp@example.com

  francois:
    nickname:   francoisz
    first_name: François
    last_name:  Zaninotto
    password:   adventcal
    email:      fz@example.com

As the setPassword() method was defined for the User class, the sfPropelData object will correctly populate the new sha1_password and salt columns defined in the schema when we call:

$ php batch/load_data.php

note

Notice that the sfPropelData object is able to deal with methods that are not bind to 'real' database column (and now we overtake your traditional SQL dump!).

If you wonder how this is possible, take a look at the database population chapter of the symfony book.

Note: There is no need to define a password for the 'Anonymous Coward' user since we will forbid him to login. And we would really appreciate that you didn't try the two passwords given here on our bank accounts, since they are confidential!

Custom validator

Now it is time to write this custom myLoginValidator. You can create it in any of the lib/ directories that are accessible to the module (that is, in askeet/lib/, or in askeet/apps/frontend/lib/, or in askeet/apps/frontend/modules/user/lib/). For now, it is considered to be an application-wide validator, so the myLoginValidator.class.php will be created in the askeet/apps/frontend/lib/ directory:

<?php
 
class myLoginValidator extends sfValidator
{    
  public function initialize($context, $parameters = null)
  {
    // initialize parent
    parent::initialize($context);
 
    // set defaults
    $this->setParameter('login_error', 'Invalid input');
 
    $this->getParameterHolder()->add($parameters);
 
    return true;
  }
 
  public function execute(&$value, &$error)
  {
    $password_param = $this->getParameter('password');
    $password = $this->getContext()->getRequest()->getParameter($password_param);
 
    $login = $value;
 
    // anonymous is not a real user
    if ($login == 'anonymous')
    {
      $error = $this->getParameter('login_error');
      return false;
    }
 
    $c = new Criteria();
    $c->add(UserPeer::NICKNAME, $login);
    $user = UserPeer::doSelectOne($c);
 
    // nickname exists?
    if ($user)
    {
      // password is OK?
      if (sha1($user->getSalt().$password) == $user->getSha1Password())
      {
        $this->getContext()->getUser()->setAuthenticated(true);
        $this->getContext()->getUser()->addCredential('subscriber');
 
        $this->getContext()->getUser()->setAttribute('subscriber_id', $user->getId(), 'subscriber');
        $this->getContext()->getUser()->setAttribute('nickname', $user->getNickname(), 'subscriber');
 
        return true;
      }
    }
 
    $error = $this->getParameter('login_error');
    return false;
  }
}

When the validator is required - after the login form submission - the initialize() method is called first. It initiates the default value of the login_error message ('Invalid Input') and merges the parameters (the ones under the param: header in the login.yml file) into the parameter holder object.

Then the execute() method is... executed. The $password_param is the field name provided in the login.yml under the password header. It is used as a field name to retrieve a value from the request parameters. So $password contains the password entered by the user. $value takes the value of the current field - and the myLoginValidator class is called for the nickname field. So $login contains the nickname entered by the user. At last! Now the validator has all the necessary data to actually validate the user.

The following code was taken off the login action. But in addition, the test of the password validity (previously always true) is implemented: A hash of the password entered by the user (using the salt stored in the database) is compared to the hashed password of the user.

If the login and the password are correct, the validator returns true and the target action of the form (executeLogin()) will be executed. If not, it returns false and it's the handleErrorLogin() that will be executed.

Remove the code from the action

Now that all the validation code is located inside the validator, we need to remove it from the login action. Indeed, when the action is called with the POST method, it means that the validator validated the request, so the user is correct. It means that the only thing that the action has to do in this case is to redirect to the referer page:

public function executeLogin()
{
  if ($this->getRequest()->getMethod() != sfRequest::POST)
  {
    // display the form
    $this->getRequest()->getParameterHolder()->set('referer', $this->getRequest()->getReferer());
 
    return sfView::SUCCESS;
  }
  else
  {
    // handle the form submission
    // redirect to last page
    return $this->redirect($this->getRequestParameter('referer', '@homepage'));
  }
}

Test the modifications by trying to login with one of the test users (after clearing the cache, since we created a new validator class that needs to be autoloaded).

Restrict access

If you want to restrict access to an action, you just need to add a security.yml in the module config/ directory, like the following (don't do it for now):

all:
  is_secure:   on
  credentials: subscriber

The actions of such a module will only be executed if the user is authenticated, and a has subscriber credential.

In askeet, login will be required to post a new question, to declare interest about a question, and to rate a comment. All the other actions will be open to non logged users.

So to restrict the access of the question/add action (yet to be written), add the following security.yml file in the askeet/apps/frontend/modules/question/config/ directory:

add:
  is_secure:   on
  credentials: subscriber

all:
  is_secure:   off

How about a bit of refactoring?

The day is almost finished, but we would like to play our favorite game for a little while: The move-the-code-to-an-unlikely-place game.

The four lines of code that are executed when the password is validated grant access to the user and save his id for future requests. You could see it as a method of the myUser class (the session class, not the User class corresponding to the User column). That's easy to do. Add the following methods to the askeet/apps/frontend/lib/myUser.php class:

public function signIn($user)
{
  $this->setAttribute('subscriber_id', $user->getId(), 'subscriber');
  $this->setAuthenticated(true);
 
  $this->addCredential('subscriber');
  $this->setAttribute('nickname', $user->getNickname(), 'subscriber');
}
 
public function signOut()
{
  $this->getAttributeHolder()->removeNamespace('subscriber');
 
  $this->setAuthenticated(false);
  $this->clearCredentials();
}

Now, change the four lines starting by $this->getContext()->getUser() in the myLoginValidator class with:

$this->getContext()->getUser()->signIn($user);

And also change the user/logout action (did you forget about this one?) by:

public function executeLogout()
{
  $this->getUser()->signOut();
 
  $this->redirect('@homepage');
}

The subscriber_id and nickname session attributes could also be abstracted through a getter method. Still in the myUser class, add the three following methods:

public function getSubscriberId()
{
  return $this->getAttribute('subscriber_id', '', 'subscriber');
}
 
public function getSubscriber()
{
  return UserPeer::retrieveByPk($this->getSubscriberId());
}
 
public function getNickname()
{
  return $this->getAttribute('nickname', '', 'subscriber');
}

You can use one of these new methods in the layout.php: change the line

<li><?php echo link_to($sf_user->getAttribute('nickname', '', 'subscriber').' profile', 'user/profile') ?></li>

by

<li><?php echo link_to($sf_user->getNickname().' profile', 'user/profile') ?></li>

Don't forget to test the modifications. The same login process as previously should still work - but now with better code.

See you Tomorrow

Tomorrow, it will be time to work a bit on the view configuration, to customize CSS, consistent components, and to take care of the page headers.

Don't forget that you can still download today's full code from the askeet SVN repository, tagged release_day_6. If you feel like asking or answering questions about askeet, feel free to pay a visit to the askeet forum. Don't forget that the program of the 21st day is still up to you.