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:

  1. Adapting Symfony2 authentication workflow to let it talk AJAX,
  2. Building the client,
  3. 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