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.
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
, andsf_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, theJobeetJob
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 historyarray_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; }
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 bysfRequest
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.
In a YAML file, a boolean can be expressed with the string
true
andfalse
, oron
andoff
.
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.
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 asecurity.yml
in theconfig/
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).
Complex Credentials
The
credentials
entry ofsecurity.yml
supports Boolean operations to describe complex credentials requirements.If a user must have credential A and B, wrap the credentials with square brackets:
index: credentials: [A, B]If a user must have credential A or B, wrap them with two pairs of square brackets:
index: credentials: [[A, B]]You can even mix and match brackets to describe any kind of Boolean expression with any number of credentials.
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 indata/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.
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 thelist
task to list all task belonging to theguard
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 theapp: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>
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 providesisAuthenticated()
andhasCredential()
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.
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)!
default: is_secure: on
but I have on access to backend, why?
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