Building a solid base for a Backbone-Symfony2 one-page app
Section intitulée we-re-going-to-talk-about-marionette-ajax-authentication-etcWe’re going to talk about Marionette, AJAX authentication, etc…
Here at JoliCode, we :heart: Symfony2 and beautiful UIs. This tutorial will be about building a solid and extensible base for your next one-page cool app. I couldn’t find many tutorials out there handling authentication and Backbone, so I thought I could write my own. I’m going to assume you already have some knowledge about Symfony2 and Backbone.
We’re going to use Symfony2 as a backend, FOSUserBundle as our user provider although it’s not strictly necessary and Backbone + Marionette on the client. Marionette is a great addition to Backbone by @derickbailey providing structure and reducing boilerplate code.
There will be 3 main steps:
- Adapting Symfony2 authentication workflow to let it talk AJAX,
- Building the client,
- Adding restricted resources only accessible to authenticated users.
The complete source code with fixtures and other assets (JS libs, Twitter Bootstrap for styling, …) can be cloned from github. You can git checkout tag v1.0
to get all the functionalities we’ll build in this tutorial.
Section intitulée plug-into-the-default-authentication-workflowPlug into the default authentication workflow
By default, the authentication workflow uses dedicated pages to display a login form and redirects. We need to register our own AuthenticationSuccess
, AuthenticationFailure
and LogoutSuccess
handlers to respond with JSON objects or error messages.
For the sake of brievity, everything is going to live in the same bundle, the PaztekHomeBundle
, but you probably want to split things in a UserBundle and Static bundle.
The first thing I did was to register a CSRF provider that always returns true so we can drop the CSRF field as this is the quickest way to disable CSRF protection on the login form. CSRF attacks are a serious threat and you should always protect your forms with CSRF tokens. CSRF attacks on login forms may be really bad if you’re in one of these cases but implementing CSRF protection in AJAX calls is out of the scope of this tutorial. Here is the CsrfProvider class :
namespace Paztek\Bundle\HomeBundle\Form\Extension\Csrf\CsrfProvider;
// ... Imports
/**
* This dummy Csrf provider is used to replace the default one (form.csrf_provider) usually used on the login form.
* It's necessary as the SecurityBundle (or the FOSUSerBundle) doesn't allow us to disable CSRF-protection only on login form
*
* @author matthieu
*
*/
class AlwaysTrueCsrfProvider extends BaseCsrfProvider
{
/**
* {@inheritDoc}
*/
public function isCsrfTokenValid($intention, $token)
{
return true;
}
}
Here is the code for the AjaxAuthenticationSuccessHandler :
namespace Paztek\Bundle\HomeBundle\Security\Http\Authentication;
// Imports ...
class AjaxAuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
{
protected $serializer;
public function setSerializer(SerializerInterface $serializer = null)
{
$this->serializer = $serializer;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token)
{
// We grab the entity associated with the logged in user or null if user not logged in
$user = $token->getUser();
// We serialize it to JSON
$json = $this->serializer->serialize($user, 'json');
// And return the response
$response = new Response($json);
$response->headers->set('Content-Type', 'application/json');
return $response;
}
}
The code is pretty straightforward. The only thing to notice is the use of a serializer provided by the JMSSerializerBundle
. It allows to specify via annotations in our User
entitiy class which properties should be serialized or not since we probably don’t want to return to the client unnecessary fields as password, salt, etc…
Our AjaxAuthenticationFailureHandler
and AjaxLogoutSuccessHandler
work the same way. you can find the code here and here.
Next we need to register these classes as services so they can be injected into the authentication workflow by the DIC. Here is the relevant part of security.yml
:
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
pattern: ^/
form_login:
provider: fos_userbundle
csrf_provider: paztek_home.always_true_csrf_provider
success_handler: paztek_home.ajax_authentication_success_handler
failure_handler: paztek_home.ajax_authentication_failure_handler
logout:
success_handler: paztek_home.ajax_logout_success_handler
anonymous: true
access_control:
- { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/admin/, role: ROLE_ADMIN }
And the services.xml
file of our bundle can be found here.
The next thing to do is to write our main controller. There will only be one route (for now), serving our homepage and all the Backbone templates used by our app :
namespace Paztek\Bundle\HomeBundle\Controller;
// Imports ...
class DefaultController extends Controller
{
public function indexAction()
{
$data = array();
// We grab the entity associated with the logged in user or null if user not logged in
$user = $this->getUser();
$data['user'] = $user;
// And pass all of it to the template to bootstrap our Backbone app
return $data;
}
}
Our index.html.twig
file extends our base.html.twig
and includes in separate twig files the templates that are going to be used by Backbone. Our base.html.twig
can be found here.
We need to dump in JSON the user entity (or null if no user is authenticated yet) so our Backbone app can use it. That’s what the block bootstrap does. The root variable is used to prefix the AJAX calls our app is going to make by the correct app_{env}.php so we can benefit from the multiple environments provided by Symfony2.
And that’s all for now on the server side. Let’s build the client.
Section intitulée our-backbone-appOur Backbone app
Backbone is a widely used library and part of its success come from the fact that it doesn’t enforce a specific architecture for your projects. Marionette is a plugin that provides a way to architect your app and reduce boilerplate code by defining ready-to-use Views, AppRouters and Applications classes.
All of our JS code is going to live in a single main.js
as it will stay very short.
On the highest level, our page will be split in 2 Marionette Regions : the header and the content. There will be a landing page for unauthenticated users, with a login button in the header. Once they login, they will be redirected to their “dashboard”, a restricted page showing some restricted resources. If they close the tab and come back later, they land directly on their dashboard.
The first thing to do is hook into jQuery AJAX calls to prefix these calls with the correct main controller:
$.ajaxPrefilter(function(options) {
options.url = root + options.url;
});
Next thing is defining our Model classes:
var Paz = {};
Paz.User = Backbone.Model.extend({
isLoggedIn: function() {
return (this.has('username')); // It works, as a user is either an empty shell or full of attributes
}
});
Paz.Alert = Backbone.Model.extend({
});
Next we define our views:
The header View, updating as the user logs in or out:
Paz.HeaderView = Marionette.ItemView.extend({
template: '#header_tpl',
initialize: function() {
this.model.bind('change', this.render);
}
});
The static homepage:
Paz.HomepageView = Marionette.ItemView.extend({
template: '#homepage_tpl'
});
The dashboard Layout. A Layout is a special View provided by Marionette that can manage subviews in regions:
Paz.DashboardView = Marionette.Layout.extend({
template: '#dashboard_tpl',
regions: {
fruits: '#fruits',
users: '#users'
},
initialize: function() {
this.model.bind('change', this.render);
}
});
The login View is responsible for catching the login form submit, submitting it in AJAX instead and handling success and failure:
Paz.LoginView = Marionette.ItemView.extend({
template: '#login_tpl',
id: 'login',
initialize: function() {
this.model = new Paz.Alert();
this.model.bind('change', this.render);
},
events: {
'submit form': 'login'
},
// We catch the submit and submit the form via AJAX instead
login: function(event) {
event.preventDefault();
var form = $(event.target);
$.ajax({
context: this,
type: 'POST',
url: 'login_check',
data: form.serialize(),
dataType: 'json',
success: function(data, textStatus, errorThrown) {
Paz.app.user.set(data);
// We redirect to the dashboard
Backbone.history.navigate('#/dashboard', { trigger: true });
},
error: function(jqXHR, textStatus, errorThrown) {
this.model.set($.parseJSON(jqXHR.responseText)); // TODO Handle edge cases of network problem and responseText = null
}
});
}
});
The next thing to do is to wire everything up with a router and to bootstrap our app:
Paz.Router = Marionette.AppRouter.extend({
routes: {
'': 'home',
'dashboard': 'dashboard',
'login': 'login',
'logout': 'logout'
},
home: function() {
// If the user is already authenticated, we display his dashboard instead
if (Paz.app.user.isLoggedIn()) {
Backbone.history.navigate('#/dashboard', { trigger: true });
} else {
// We display the header and the homepage
this.showHeader();
this.showHomepage();
}
},
dashboard: function() {
// If the user isn't authenticated yet, we redirect to the login page
if (!Paz.app.user.isLoggedIn()) {
Backbone.history.navigate('#/login', { trigger: true });
} else {
// We display the header and the dashboard
this.showHeader();
this.showDashboard();
}
},
login: function() {
// If the user is already authenticated, we 'redirect' to the home
if (Paz.app.user.isLoggedIn()) {
Backbone.history.navigate('#/dashboard', { trigger: true });
}
// else we display the header and the login form
else {
this.showHeader();
this.showLogin();
}
},
logout: function() {
$.ajax({
context: this,
type: 'GET',
url: 'logout',
dataType: 'json',
success: function(data, textStatus, errorThrown) {
Paz.app.user.clear();
Backbone.history.navigate('#', { trigger: true });
}
});
},
showHeader: function() {
// We generate the header only if it doesn't exist yet
if (!Paz.app.header.currentView) {
var headerView = new Paz.HeaderView({
model: Paz.app.user
});
Paz.app.header.show(headerView);
}
},
showHomepage: function() {
var homepageView = new Paz.HomepageView();
Paz.app.content.show(homepageView);
},
showDashboard: function() {
var dashboardView = new Paz.DashboardView({
model: Paz.app.user
});
Paz.app.content.show(dashboardView);
},
showLogin: function() {
var loginView = new Paz.LoginView();
Paz.app.content.show(loginView);
}
});
Paz.app = new Marionette.Application();
Paz.app.addRegions({
header: '#header',
content: '#content'
});
/**
* We bootstrap the app :
* 1) Instantiate the user
* 2) Launch the router and process the first route
*/
Paz.app.addInitializer(function(options) {
this.user = new Paz.User(user); // Here we use the user data dumped in JSON in the template by our backend
});
Paz.app.addInitializer(function(options) {
this.router = new Paz.Router();
Backbone.history.start({ root: root });
});
/**
* Now we launch the app
*/
Paz.app.start();
And that’s all we need for the client.
Section intitulée showing-some-restricted-resources-on-the-dashboardShowing some restricted resources on the dashboard
We’re going to add an entity named Fruit
, coz it’s healthy. The list of fruits will be displayed on the dashboard of any authenticated user while the list of users will only be accessible to users with ROLE_ADMIN.
Let’s modify our DefaultController
to reflect those changes:
In the index action:
// We query the resources needed for the dashboard based on user's roles
if ($this->container->get('security.context')->isGranted('ROLE_USER')) {
$fruits = $this->getDoctrine()->getRepository('PaztekHomeBundle:Fruit')->findAll();
$data['fruits'] = $fruits;
}
if ($this->container->get('security.context')->isGranted('ROLE_ADMIN')) {
$users = $this->getDoctrine()->getRepository('PaztekHomeBundle:User')->findAll();
$data['users'] = $users;
}
And we need to add new routes to load users and fruits in JSON after login too (when they aren’t already bootstrapped at landing).
In the template, we bootstrap these data the same way we did with the user :
{% if fruits is defined %}
var fruits = {{ fruits | serialize | raw }};
{% endif %}
{% if users is defined %}
var users = {{ users | serialize | raw }};
{% endif %}
On the client, we need to hook into the login success callback to query the fruits (and the users if user has ROLE_ADMIN), we need to delete them on logout. And we modify a bit the showDashboard function of our AppRouter to instantiate subviews for the fruits list and the user list.
Section intitulée conclusionConclusion
This might look like a lot of code to only handle authentication but I think that we established a solid base for any one-page app requiring some sort of authentication and providing different views based on user’s roles.
As I said above, there are many ways to architect your app and if you know a better way to build this piece of functionality, feel free to let me know in the comments. For instance, things could be a little more decoupled : we could trigger an event on login and logout and handle tasks like downloading new data/deleting data in a separate object.
Commentaires et discussions
Ces clients ont profité de notre expertise
La société AramisAuto a fait appel à JoliCode pour développer au forfait leur plateforme B2B. L’objectif était de développer une nouvelle offre à destination des professionnels ; déjà testé commercialement, pro.aramisauto.com est la concrétisation de 3 mois de développement. Le service est indépendant de l’infrastructure existante grâce à la mise en…
Nous avons réalisé la refonte du site de l’agence Beautiful Monday en utilisant nos compétences HTML5/CSS3 côté front-end, et le framework Symfony2 côté back-end. Afin de s’afficher correctement sur n’importe quel appareil, le site est entièrement responsive. La partie intégration a été effectuée avec un grand soin, en respectant parfaitement la maquette…
Une autre de nos missions a consisté à réaliser le portail partenaire destiné aux utilisateurs des API Arte. Cette application Web permet de gérer les différentes applications clé nécessaires pour utiliser l’API et exposer l’ensemble de la documentation.