Sunday, September 13, 2015

Boilerplate code for AngularJS, NodeJS, JWT and TypeScript

Pre-requisite:

JSON Web Token module:
npm install jwt-simple


Not pre-requisites. Makes UI development convenient.
* ui-router
* sweetAlert


For persistence of token even when the browser is refreshed, $window.sessionStorage would suffice, no need to use ng-storage. However, it's better to use $window.localStorage, $window.sessionStorage is for single tab only; if we open the same app in another tab, it won't be able to read the sessionStorage from the original tab. $window.localStorage can be accessed between tabs.


Authentication consist mainly of these parts:


Server-side:
* Auth.ts -- username and password authentication goes here
* CanBe.ts -- authorization goes here
* app.ts -- entry point of a node app

This is how an API can be authorized in nodejs's app.js. Insert the CanBe.accessedBy node RequestHandler before the API to be called, e.g.,

Without authorization:
app.get('/api/item', itemApi.search);


With authorization:
app.get('/api/item-secured', canBe.accessedBy(['rockstar']), itemApi.search);


Shared by server-side and client-side:
* ILoginDto.ts -- username and password transmitted from client-side to server-side
* ILoginResponseDto.ts -- Auth.ts's authentication response
* ITokenPayload.ts -- Not exactly used by client-side, if there's a need for the client-side to decode the middle part of the JWT, it goes in this data structure. The first part and middle part of JWT are publicly-available information, and as such, ITokenPayload should contain no sensitive information, e.g., password


Client-side:
* init.ts -- called by main.js. main.js is the conventional name for the app's entry point in a RequireJS-based apps.
* AuthService.ts -- called by login UI, to decouple the login controller if there will be changes in authentication mechanism
* HttpInterceptor.ts -- the money shot. this is where to make an angular application automatically send a header everytime an $http or $resource call happens



Auth.ts:
import express = require('express');

import jwt = require('jwt-simple');

export function verify(req: express.Request, res:express.Response, next: Function) : any {
    
    var loginDto = <shared.ILoginDto> req.body;
    
    var loginResponseDto = <shared.ILoginResponseDto> {};
    
    var isValidUser = dbAuthenticate(loginDto.username, loginDto.password);
    
    if (isValidUser) {
        
        var roles = dbGetUserRoles(loginDto.username);
        
        var tokenPayload = <shared.ITokenPayload> { username: loginDto.username, roles: roles };
    
        var payloadWithSignature = jwt.encode(tokenPayload, "safeguardThisSuperSecretKey");
    
        loginResponseDto.isUserValid = true;
        loginResponseDto.theAuthorizationToUse = "Bearer " + payloadWithSignature;
        
        res.json(loginResponseDto);            
    }
    else {
        loginResponseDto.isUserValid = false;
    
        res.status(401).json(loginResponseDto);    
    }    
}


function dbAuthenticate(username: string, password: string): any {
    
    if (username == "Thom" && password == "Yorke") {
        return true;
    }
    
    if (username == "Kurt" && password == "Cobain") {
        return true;
    }
    
    return false;
}


function dbGetUserRoles(username: string): string[] {
    
    if (username == "Thom") {
        return ["rockstar", "composer"];
    }
    
    if (username == "Kurt") {
        return ["composer"];
    }
    
    return [];
}


CanBe.ts:
import express = require('express');

import jwt = require('jwt-simple');


export function accessedBy(roles: string[]):any {
    accessedByBind.bind(roles);
}

function accessedByBind(roles:string[], req:express.Request, res:express.Response, next:Function): any {
    
    function unauthorized(): any {
        return res.sendStatus(401);
    }
    
    // === accessedByBind starts here ===
    
    var theAuthorizationToUse = req.headers["authorization"];
    
    if (theAuthorizationToUse === undefined) {
        return unauthorized();
    }
    
    var payloadWithSignature = theAuthorizationToUse.substr("Bearer ".length); 
    
    var tokenPayload = <shared.ITokenPayload> jwt.decode(payloadWithSignature, "safeguardThisSuperSecretKey");

    if (tokenPayload == undefined || tokenPayload.username == "") {
        return unauthorized();
    }
    
    if (tokenPayload.roles.filter(memberRole => roles.filter(allowedRole => memberRole == allowedRole).length > 0 ).length > 0) {
        return next();
    }
    
    return res.status(500).json({customError: "Unknonwn Error"});
    
}



ILoginDto.ts:
module shared {
    
    export interface ILoginDto {
        username: string;
        password: string;
    }
    
}

ILoginResponseDto.ts:
module shared {

    export interface ILoginResponseDto {
        isUserValid: boolean;
        theAuthorizationToUse: string;
    }
    
}

ITokenPayload.ts:
module shared {
    
    export interface ITokenPayload {
    
        username: string;
        roles: string[];
    
    }
}



init.js
// app initialization and dependency injections goes here

var angie:ng.IAngularStatic = require('angular');

var mod:ng.IModule = angie.module('niceApp', [
    'ui.router', 'ngResource', 'scs.couch-potato', 'ngFileUpload',
    'ngSanitize', 'ui.pagedown', 'ngTagsInput', 'puElasticInput', 'ui.bootstrap']);

mod.service('AuthService', ['$http', '$q', '$window', InhouseLib.AuthService]);
mod.factory('HttpInterceptor', ['$q', '$window', '$injector', 'TheSweetAlert', '$location', InhouseLib.HttpInterceptor]);

mod.config(['$httpProvider', ($httpProvider:ng.IHttpProvider) => {
    $httpProvider.interceptors.push('HttpInterceptor');
}]);


AuthService.ts:
module InhouseLib {

    export class AuthService {


        constructor(public $http:ng.IHttpService, public $q:ng.IQService, public $window: ng.IWindowService) {
        }

        verify(username:string, password:string):ng.IPromise<ng.IHttpPromiseCallbackArg<shared.ILoginResponseDto>> {

            var deferred = this.$q.defer();

            var u = <shared.ILoginDto>{};
            u.username = username;
            u.password = password;



            this.$http.post<shared.ILoginResponseDto>('/api/member/verify', u)
                .then(success => {

                    this.$window.localStorage["theToken"] = success.data.theAuthorizationToUse;

                    // This goes to then's success callback parameter
                    deferred.resolve(success);

                }, error => {

                    this.logout();

                    // this goes to then's error callback parameter
                    deferred.reject(error);

                });

            return deferred.promise;

        }

        logout(): void {
            delete this.$window.localStorage["theToken"];
        }

    }
}


HttpInterceptor.ts:
module InhouseLib {

    export function HttpInterceptor($q:ng.IQService,
                                    $window:ng.IWindowService,
                                    $injector:ng.auto.IInjectorService,
                                    TheSweetAlert,
                                    $location:ng.ILocationService)
    {

        return {
            request: (config):any => {
                config.headers = config.headers || {};

                var theToken = $window.localStorage["theToken"];

                if (theToken !== undefined) {
                    // token already include the word "Bearer "
                    config.headers.Authorization = theToken;
                }
                return config;
            },

            responseError: (response):any => {
                if (response.status === 401 || response.status === 403) {

                    // http://stackoverflow.com/questions/25495942/circular-dependency-found-http-templatefactory-view-state
                  
                    // ui-router's $state
                    // can use the $location.path(paramHere) to change the url. but since we are using ui-router
                    // it's better to use its $state component to route things around.
                    var $state:any = $injector.get('$state');

                    TheSweetAlert({title: "Oops", text: "Not allowed. Will redirect you to login page\n" +
                        "This is your current url: " + $location.path()}, () =>
                    {
                        $state.go('root.login');
                    });


                }
                return $q.reject(response);
            }
        };
    }// function HttpInterceptor

}

No comments:

Post a Comment