All Articles

Authentication in a SPA with Angular

There is no doubt that Single Page Applications (SPA) is a trend that is growing more and more each day.  However, developing a SPA is harder than the traditional approach, in which the pages are loaded, maybe there is some ajax on the page with jQuery, and when you navigate away to another page a full reload occurs.

When I started my journey developing SPAs, I had to rethink again on some aspects of the application, and check what is the best approach to solve some plumbing problems, such as authentication, authorization, error handling,logging, i18n, etc.

Regarding authentication, I think there are two major approaches:

  1. consider authentication as a separate page, and we only load our SPA, when the user is authenticated
  2. I have a pure SPA, and authentication occurs inside the app, which means there is no need to reload, since the SPA is already loaded.

In both approaches I am assuming and taking as granted that the server always check for access-control (all access control in the client side can be overcome) and that we have an http interceptor that checks the HTTP status response, and if we get an Unauthorized, we redirect the user to the login screen.

In my current application I am working on, I opted for option 2, and I would like to show you how I implemented with Angular, which is my MV* web framework of choice.

Fist of all, I have an Angular service, AuthService, which is responsible to provide helper authentication methods to the app.

(function() {    
    'use strict';

    angular.module('app').factory('authService' , ['$rootScope', 'Restangular', function ($rootScope, Restangular) {

        var user = {
            isAuthenticated: false,
            name: ''
        };

        $rootScope.user = user;

        var authService = {};

        authService.init = function (isAuthenticated, userName) {
            user.isAuthenticated = isAuthenticated;
            user.name = userName;
        };

        authService.isAuthenticated = function () {
            return user.isAuthenticated;
        };

        authService.login = function (loginModel) {

            var loginResult = Restangular.all('accounts').customPOST(loginModel, 'login');

            loginResult.then(function (result) {
                user.isAuthenticated = result.loginOk;
                if (result.loginOk)
                    user.name = loginModel.userName;
            });

            return loginResult;
        };

        authService.logout = function () {
            return Restangular.all('accounts').customPOST(null, 'logout')
            .then(function (result) {
                user.isAuthenticated = false;
                user.name = '';
            });

        };

        authService.register = function (registerModel) {
            return Restangular.all('accounts').customPOST(registerModel, 'register');
        };

        authService.changePassword = function (changePasswordModel) {
            return Restangular.all('accounts').customPUT(changePasswordModel, 'changePassword');
        };

        return authService;
    }]);
})();    

My fist implementation consisted of marking routes that need authentication, and detect when one of this routes are in place. The Angular route service provides an event, $routeChangeStart, every time a route changes. Here is the first implementation, that check routes with based on field allowAnonymous

(function () {
    'use strict';

    angular.module('app')
    .constant('APP_ROOT', angular.element('#linkAppRoot').attr('href'))
    .config(['$routeProvider', '$httpProvider', function ($routeProvider, $httpProvider) {

        $routeProvider
            .when('/', { redirectTo: '/integrations' })
            .when('/login', { templateUrl: 'app/views/login.html', controller: 'LoginCtrl', allowAnonymous: true })
            .when('/register', { templateUrl: 'app/views/register.html', controller: 'RegisterCtrl', allowAnonymous: true })
            .when('/integrations', { templateUrl: 'app/views/integrations.html', controller: 'IntegrationsCtrl' })
            .when('/integration/new', { templateUrl: 'app/views/editIntegration.html', controller: 'EditIntegrationCtrl' })
            .when('/integration/:integrationId', { templateUrl: 'app/views/editIntegration.html', controller: 'EditIntegrationCtrl' })
            .when('/activity', { templateUrl: 'app/views/activity.html', controller: 'ActivityCtrl' })
            .when('/changePassword', { templateUrl: 'app/views/changePassword.html', controller: 'ChangePasswordCtrl' })
            //.when('/settings', { templateUrl: 'app/views/settings.html', controller: 'SettingsCtrl' })
            .when('/externalaccounts', { templateUrl: 'app/views/externalAccounts.html', controller: 'ExternalAccountsCtrl' })
            .when('/404', { templateUrl: 'app/views/404.html' })
            .otherwise({ redirectTo: '/404' });

        $httpProvider.interceptors.push('processErrorHttpInterceptor');
    }]);


    angular.module('app').run(['$location', '$rootScope', '$log', 'authService', '$route',
        function ($location, $rootScope, $log, authService, $route) {

            function handleRouteChangeStart(event, next, current) {

                if (!next.allowAnonymous && !authService.isAuthenticated()) {
                    $log.log('authentication required. redirect to login');

                    var returnUrl = $location.url();
                    $log.log('returnUrl=' + returnUrl);

                    //TODO: BUG -> THIS IS NOT PREVENTING THE CURRENT ROUTE
                    //This has a side effect, which is load of the controller/view configured to the route
                    //The redirect to login occurs later.
                    //Possible solutions: 
                    // 1 - use $locationChangeStart (it is hard to get the route being used)
                    // 2 - Use a resolver in controller, returning a promise, and reject when needs auth
                    event.preventDefault();

                    $location.path('/login').search({ returnUrl: returnUrl })

                }
            }

            $rootScope.$on('$routeChangeStart', handleRouteChangeStart);

        }]);
})();

However, there is a problem with this solution, since the code e.preventDefault() it does nothing. Basically the route is not prevented, which means that the destination controller is loaded, and eventually some API calls to the server are being done. In functional terms the user is redirected to the login screen, however we can have API calls that are not necessary due the fact that the controller is instantiated.

So, the correct approach consists to prevent the route to succeed. How can we do that in Angular? Well, in Angular, we can participate in the resolving process  before the route succeeds. Here is the Angular documentation for the when() method of $routeProvider:

resolve - {Object.<string, function>=} - An optional map of dependencies which should be injected into the controller. If any of these dependencies are promises, the router will wait for them all to be resolved or one to be rejected before the controller is instantiated. If all the promises are resolved successfully, the values of the resolved promises are injected and $routeChangeSuccess event is fired. If any of the promises are rejected the $routeChangeError event is fired.

So, if we have a promise that is rejected when the user tries to achieve a route that needs authentication and the user is not authenticated, then the route is aborted and the event $routeChangeError is fired.

Based on this fact, here is the final implementation

(function () {
    'use strict';

    angular.module('app')
    .constant('APP_ROOT', angular.element('#linkAppRoot').attr('href'))
    .config(['$routeProvider', '$httpProvider', '$locationProvider', function ($routeProvider, $httpProvider, $locationProvider) {

        function checkLoggedIn($q, $log, authService) {
            var deferred = $q.defer();

            if (!authService.isAuthenticated()) {
                $log.log('authentication required. redirect to login');
                deferred.reject({ needsAuthentication: true });
            } else {
                deferred.resolve();
            }

            return deferred.promise;
        }

        $routeProvider.whenAuthenticated = function (path, route) {
            route.resolve = route.resolve || {};
            angular.extend(route.resolve, { isLoggedIn: ['$q', '$log', 'authService', checkLoggedIn] });
            return $routeProvider.when(path, route);
        }


        $routeProvider
            .when('/', { redirectTo: '/integrations' })
            .when('/login', { templateUrl: 'app/views/login.html', controller: 'LoginCtrl' })
            .when('/register', { templateUrl: 'app/views/register.html', controller: 'RegisterCtrl' })
            .whenAuthenticated('/integrations', { templateUrl: 'app/views/integrations.html', controller: 'IntegrationsCtrl' })
            .whenAuthenticated('/integration/new/:integrationType', { templateUrl: 'app/views/editIntegration.html', controller: 'EditIntegrationCtrl' })
            .whenAuthenticated('/integration/:integrationType/:integrationId', { templateUrl: 'app/views/editIntegration.html', controller: 'EditIntegrationCtrl' })
            .whenAuthenticated('/activity', { templateUrl: 'app/views/activity.html', controller: 'ActivityCtrl' })
            .whenAuthenticated('/changePassword', { templateUrl: 'app/views/changePassword.html', controller: 'ChangePasswordCtrl' })
            //.whenAuthenticated('/settings', { templateUrl: 'app/views/settings.html', controller: 'SettingsCtrl' })
            .whenAuthenticated('/externalaccounts', { templateUrl: 'app/views/externalAccounts.html', controller: 'ExternalAccountsCtrl' })
            .when('/404', { templateUrl: 'app/views/404.html', controller: 'NotFoundErrorCtrl' })
            .when('/apierror', {templateUrl: 'app/views/apierror.html', controller: 'ApiErrorCtrl' })
            .otherwise({ redirectTo: '/404' });

        $httpProvider.interceptors.push('processErrorHttpInterceptor');
    }]);

    angular.module('app').run(['$location', '$rootScope', '$log', 'authService', '$route',
        function ($location, $rootScope, $log, authService, $route,) {

            $rootScope.$on('$routeChangeError', function (ev, current, previous, rejection) {
                if (rejection &amp;&amp; rejection.needsAuthentication === true) {
                    var returnUrl = $location.url();
                    $log.log('returnUrl=' + returnUrl);
                    $location.path('/login').search({ returnUrl: returnUrl });
                }
            });

        }]);

})();

Basically the method whenAuthenticated adds the checkLoggedIn resolver to the route resolve object, which asks to the authService if the current user is authenticated. If not, it rejects the promise, and the $routeChangeError is raised, which in turn is handled redirecting to login route.

I hope it helps

 

 

Published Nov 4, 2013

Cloud Solutions and Software Engineer. Married and father of two sons. Obsessed to be a continuous learner.