Jobeet - Day 13: The User

This post was published as part of the symfony 2008 advent calendar. As this tutorial might have been updated since then, you are advised to read the last version from the symfony 1.2 documentation (for Propel or Doctrine).

Previously on Jobeet

Yesterday was packed with a lot of information. With very few PHP lines of code, the symfony admin generator allows the developer to create backend interfaces in a matter of minutes.

Today, we will discover how symfony manages persistent data between HTTP requests. As you might know, the HTTP protocol is stateless, which means that each request is independent from it preceding or proceeding ones. Modern websites need a way to persist data between requests to enhance the user experience.

A user session can be identified using a cookie. In symfony, the developer does not need to manipulate the session directly, but rather uses the sfUser object, which represents the application end user.

User Flashes

We have already seen the user object in action with flashes. A flash is an ephemeral message stored in the user session that will be automatically deleted after the very next request. It is very useful when you need to display a message to the user after a redirect. The admin generator uses flashes a lot to display feedback to the user whenever a job is saved, deleted, or extended.

Flashes

A flash is set by using the setFlash() method of sfUser:

// apps/frontend/modules/job/actions/actions.class.php
public function executeExtend(sfWebRequest $request)
{
  $request->checkCSRFProtection();
 
  $job = $this->getRoute()->getObject();
  $this->forward404Unless($job->extend());
 
  $this->getUser()->setFlash('notice', sprintf('Your job validity has been extend until %s.', $job->getExpiresAt('m/d/Y')));
 
  $this->redirect($this->generateUrl('job_show_user', $job));
}
 

The first argument is the identifier of the flash and the second one is the message to display. You can define whatever flashes you want, but notice and error are two of the more common ones (they are used by the admin generator).

It is up to the developer to include the flash message in the templates. For Jobeet, they are output by the layout.php:

// apps/frontend/templates/layout.php
<?php if ($sf_user->hasFlash('notice')): ?>
  <div class="flash_notice"><?php echo $sf_user->getFlash('notice') ?></div>
<?php endif; ?>
 
<?php if ($sf_user->hasFlash('error')): ?>
  <div class="flash_error"><?php echo $sf_user->getFlash('error') ?></div>
<?php endif; ?>
 

In a template, the user is accessible via the special sf_user variable.

Some symfony objects are always accessible in the templates, without the need to explicitly pass them from the action: sf_request, sf_user, and sf_response.

User Attributes

Unfortunately, the Jobeet user stories have no requirement that includes storing something in the user session. So let's add a new requirement: to ease job browsing, the last three jobs viewed by the user should be displayed in the menu with links to come back to the job page later on.

When a user access a job page, the displayed job object needs to be added in the user history and stored in the session:

// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->job = $this->getRoute()->getObject();
 
    // fetch jobs already stored in the job history
    $jobs = $this->getUser()->getAttribute('job_history', array());
 
    // add the current job at the beginning of the array
    array_unshift($jobs, $job->getId());
 
    // store the new job history back into the session
    $this->getUser()->setAttribute('job_history', $jobs);
  }
 
  // ...
}
 

We could have feasibly stored the JobeetJob objects directly into the session. This is strongly discouraged because the session variables are serialized between requests. And when the session is loaded, the JobeetJob objects are de-serialized and can be "stalled" if they have been modified or deleted in the meantime.

getAttribute(), setAttribute()

Given an identifier, the sfUser::getAttribute() method fetches values from the user session. Conversely, the setAttribute() method stores any PHP variable in the session for a given identifier.

The getAttribute() method also takes an optional default value to return if the identifier is not yet defined.

The default value taken by the getAttribute() method is a shortcut for:

if (!$value = $this->getAttribute('job_history'))
{
  $value = array();
}
 

The myUser class

To better respect the separation of concerns, let's move the code to the myUser class. The myUser class overrides the default symfony base sfUser class with application specific behaviors:

// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->job = $this->getRoute()->getObject();
 
    $this->getUser()->addJobToHistory($this->job);
  }
 
  // ...
}
 
// apps/frontend/lib/myUser.class.php
class myUser extends sfBasicSecurityUser
{
  public function addJobToHistory(JobeetJob $job)
  {
    $ids = $this->getAttribute('job_history', array());
 
    if (!in_array($job->getId(), $ids))
    {
      array_unshift($ids, $job->getId());
 
      $this->setAttribute('job_history', array_slice($ids, 0, 3));
    }
  }
}
 

The code has also been changed to take into account all the requirements:

  • !in_array($job->getId(), $ids): A job cannot be stored twice in the history

  • array_slice($ids, 0, 3): Only the latest three jobs viewed by the user are displayed

In the layout, add the following code before the $sf_content variable is output:

// apps/frontend/templates/layout.php
<div id="job_history">
  Recent viewed jobs:
  <ul>
    <?php foreach ($sf_user->getJobHistory() as $job): ?>
      <li>
        <?php echo link_to($job->getPosition().' - '.$job->getCompany(), 'job_show_user', $job) ?>
      </li>
    <?php endforeach; ?>
  </ul>
</div>
 
<div class="content">
  <?php echo $sf_content ?>
</div>
 

The layout uses a new getJobHistory() method to retrieve the current job history:

// apps/frontend/lib/myUser.class.php
class myUser extends sfBasicSecurityUser
{
  public function getJobHistory()
  {
    $ids = $this->getAttribute('job_history', array());
 
    return JobeetJobPeer::retrieveByPKs($ids);
  }
 
  // ...
}
 

The getJobHistory() method uses the Propel retrieveByPKs() method to retrieve several JobeetJob objects in one call.

To make it look better, add the following code at the end of the main.css stylesheet:

/* web/css/main.css */
#job_history
{
  padding: 7px;
  background: #eee;
  font-size: 80%;
}
 
#job_history ul
{
  display: inline;
}
 
#job_history li
{
  margin-right: 10px;
  display: inline;
}
 

Job history

sfParameterHolder

To complete the job history API, let's add a method to reset the history:

// apps/frontend/lib/myUser.class.php
class myUser extends sfBasicSecurityUser
{
  public function resetJobHistory()
  {
    $this->getAttributeHolder()->remove('job_history');
  }
 
  // ...
}
 

The user attributes are managed by an object of class sfParameterHolder. The getAttribute() and setAttribute() methods are proxy methods for getParameterHolder()->get() and getParameterHolder()->set(). As the remove() method has no proxy method in sfUser, you need to use the parameter holder object directly.

The sfParameterHolder class is also used by sfRequest to store its parameters.

Application Security

Authentication

Like many other symfony features, security is managed by a YAML file, security.yml. For instance, you can find the default configuration for the backend application in the config/ directory:

// apps/backend/config/security.yml
default:
  is_secure: off
 

If you switch the is_secure entry to on, the entire backend application will require the user to be authenticated.

Login

In a YAML file, a boolean can be expressed with the string true and false, or on and off.

If you have a look at the logs in the web debug toolbar, you will notice that the executeLogin() method of the defaultActions class is called for every page you try to access.

Web debug

When an un-authenticated user tries to access a secured action, symfony forwards the request to the login action configured in settings.yml:

all:
  .actions:
    login_module: default
    login_action: login
 

It is not possible to secure the login action. This is to avoid recursion.

As we saw during day 4, the same configuration file can be defined in several places. This is also the case for security.yml. To only secure or un-secure a single action or a whole module, create a security.yml in the config/ directory of the module:

index:
  is_secure: off
all:
  is_secure: on
 

By default, the myUser class extends sfBasicSecurityUser, and not sfUser. sfBasicSecurityUser provides additional methods to manage user authentication and authorization.

To manage user authentication, use the isAuthenticated() and setAuthenticated() methods:

if (!$this->getUser()->isAuthenticated())
{
  $this->getUser()->setAuthenticated(true);
}
 

Authorization

When a user is authenticated, the access to some actions can be even more restricted by defining credentials. A user must have the required credentials to access the page:

default:
  is_secure:   off
  credentials: admin
 

The credential system of symfony is quite simple and powerful. A credential can represent anything you need to describe the application security model (like groups or permissions).

To manage the user credentials, sfBasicSecurityUser provides several methods:

// Add one or more credentials
$user->addCredential('foo');
$user->addCredentials('foo', 'bar');
 
// Check if the user has a credential
echo $user->hasCredential('foo');                      =>   true
 
// Check if the user has both credentials
echo $user->hasCredential(array('foo', 'bar'));        =>   true
 
// Check if the user has one of the credentials
echo $user->hasCredential(array('foo', 'bar'), false); =>   true
 
// Remove a credential
$user->removeCredential('foo');
echo $user->hasCredential('foo');                      =>   false
 
// Remove all credentials (useful in the logout process)
$user->clearCredentials();
echo $user->hasCredential('bar');                      =>   false
 

For the Jobeet backend, we won't use any credentials as we only have one profile: the administrator.

Plugins

As we don't like to reinvent the wheel, we won't develop the login action from scratch. Instead, we will install a symfony plugin.

One of the great strengths of the symfony framework is the plugin ecosystem. As we will see in coming days, it is very easy to create a plugin. It is also quite powerful, as a plugin can contain anything from configuration to modules and assets.

Today, we will install sfGuardPlugin to secure the backend application:

$ php symfony plugin:install sfGuardPlugin

The plugin:install task installs a plugin by name. All plugins are stored under the plugins/ directory and each one has its own directory named after the plugin name.

PEAR must be installed for the plugin:install task to work.

When you install a plugin with the plugin:install task, symfony installs the latest stable version of it. To install a specific version of a plugin, pass the --release option. The plugin page lists all available version grouped by symfony version.

As a plugin is self-contained into a directory, you can also download the package from the symfony website and unarchive it, or alternatively make an svn:externals link to its Subversion repository.

Backend Security

Each plugin has a README file that explains how to configure it.

Let's see how to configure sfGuardPlugin. As the plugin provides several new model classes to manage users, groups, and permissions, you need to rebuild your model:

$ php symfony propel:build-all-load

Remember that the propel:build-all-load task removes all existing tables before re-creating them. To avoid this, you can build the models, forms, and filters, and then, create the new tables by executing the generated SQL statements stored in data/sql/plugins.sfGuardAuth.lib.model.schema.sql.

As always, when new classes are created, you need to clear the symfony cache:

$ php symfony cc

As sfGuardPlugin adds several methods to the user class, you need to change the base class of myUser to sfGuardSecurityUser:

// apps/backend/lib/myUser.class.php
class myUser extends sfGuardSecurityUser
{
}
 

sfGuardPlugin provides a default action to authenticate users:

// apps/backend/config/settings.yml
all:
  .settings:
    enabled_modules: [default, sfGuardAuth]
 
    # ...
 
  .actions:
    login_module:    sfGuardAuth
    login_action:    signin
 
    # ...
 

As plugins are shared amongst all applications of a project, you need to explicitly enable the modules you want to use with the enabled_modules setting.

sfGuardPlugin login

The last step is to create an administrator user:

$ php symfony guard:create-user fabien $ecretPa$$
$ php symfony guard:promote fabien

The sfGuardPlugin provides tasks to manage users, groups, and permissions from the command line. Use the list task to list all task belonging to the guard namespace:

$ php symfony list guard

When the user if not authenticated, we need to hide the menu bar:

// apps/backend/templates/layout.php
<?php if ($sf_user->isAuthenticated()): ?>
  <div id="menu">
    <ul>
      <li><?php echo link_to('Jobs', '@jobeet_job') ?></li>
      <li><?php echo link_to('Categories', '@jobeet_category') ?></li>
    </ul>
  </div>
<?php endif; ?>
 

When the user is authenticated, we need to add a logout link in the menu:

// apps/backend/templates/layout.php
<li><?php echo link_to('Logout', '@sf_guard_signout') ?></li>
 

To list all routes provided by sfGuardPlugin, use the app:routes task.

To polish the Jobeet backend even more, let's add a new module to manage the administrator users. Thankfully, sfGuardPlugin provides such a module. As for the sfGuardAuth module, you need to enable it in settings.yml:

// apps/backend/config/settings.yml
all:
  .settings:
    enabled_modules: [default, sfGuardAuth, sfGuardUser]
 

Add a link in the menu:

// apps/backend/templates/layout.php
<li><?php echo link_to('Users', '@sf_guard_user') ?></li>
 

Backend menu

We are done!

User Testing

Today's tutorial is not over as we have not yet talked about user testing. As the symfony browser simulates cookies, it is quite easy to test user behaviors by using the built-in sfTesterUser tester.

Let's update the functional tests for the menu feature we have added today. Add the following code at the end of the job module functional tests:

// test/functional/frontend/jobActionsTest.php
$browser->
  info('4 - User job history')->
 
  loadData()->
  restart()->
 
  info('  4.1 - When the user access a job, it is added to its history')->
  get('/')->
  click('Web Developer', array('position' => 1))->
  get('/')->
  with('user')->begin()->
    isAttribute('job_history', array($browser->getMostRecentProgrammingJob()->getId()))->
  end()->
 
  info('  4.2 - A job is not added twice in the history')->
  click('Web Developer', array('position' => 1))->
  get('/')->
  with('user')->begin()->
    isAttribute('job_history', array($browser->getMostRecentProgrammingJob()->getId()))->
  end()
;
 

To ease testing, we first reload the fixtures data and restart the browser to start with a clean session.

The isAttribute() method checks a given user attribute.

The sfTesterUser tester also provides isAuthenticated() and hasCredential() methods to test user authentication and autorizations.

See you Tomorrow

The symfony user classes are a nice way to abstract the PHP session management. Coupled with the great symfony plugin system and the sfGuardPlugin plugin, we have been able to secure the Jobeet backend in a matter of minutes. And we have even added a clean interface to manage our administrator users for free, thanks to the modules provided by the plugin.

Tomorrow will be the last day of the second week of the Jobeet tutorial, and we will try to make it useful again.

Comments

Nice work again
i would like to see how to use the sfguard Profile option correctly in one of the next days
Nice job.
It would be nice to see the best way to protect the login page with ssl in one of the next days.
I agree with Grimm I hope to see how you face the problem of having multiple profile, it is a really commom subject on all the symofny mailing lists.

BTW great work with jobeet
Great tutorial. Would be great to see some more of sfGuard in the next tutorial(s)!
1) After installed plugin in backend\config\security.yml I have

default:
is_secure: on

but I have on access to backend, why?

2) After logout I see blank page. How do redirect to backend?
after installing sfGuardPlugin I try to do some actions on the job form in the backend I get this 500 | Internal Server Error | sfValidatorErrorSchema
_csrf_token [CSRF attack detected.]
sfValidatorSchema.class.php line 109

Comments are closed.

To ensure that comments stay relevant, they are closed for old posts.