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:
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.
This work is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.