From fc5563c70c8c3e7609793b23b9213c4531183bdd Mon Sep 17 00:00:00 2001 From: Sahat Yalkabov Date: Wed, 13 Nov 2013 17:16:06 -0500 Subject: [PATCH] added sample app for client-server authentication --- LICENSE | 2 +- client/css/.gitignore | 0 client/css/app.css | 51 ++++++ client/img/.gitignore | 0 client/js/app.js | 71 +++++++++ client/js/controllers.js | 92 +++++++++++ client/js/directives.js | 55 +++++++ client/js/filters.js | 0 client/js/routingConfig.js | 97 ++++++++++++ client/js/services.js | 62 ++++++++ client/views/index.jade | 66 ++++++++ client/views/partials/.gitignore | 0 client/views/partials/404.jade | 2 + client/views/partials/admin.jade | 20 +++ client/views/partials/home.jade | 2 + client/views/partials/login.jade | 42 +++++ client/views/partials/private.jade | 2 + client/views/partials/register.jade | 30 ++++ controllers/index.js | 12 -- gruntfile.js | 87 ---------- models/user.js | 14 +- server.js | 14 ++ server/controllers/auth.js | 46 ++++++ server/controllers/user.js | 17 ++ server/models/User.js | 175 +++++++++++++++++++++ server/routes.js | 155 ++++++++++++++++++ server/tests/integration/index.spec.js | 57 +++++++ server/tests/unit/controllers/auth.spec.js | 102 ++++++++++++ server2.js | 37 +++++ 29 files changed, 1199 insertions(+), 111 deletions(-) create mode 100755 client/css/.gitignore create mode 100755 client/css/app.css create mode 100755 client/img/.gitignore create mode 100755 client/js/app.js create mode 100755 client/js/controllers.js create mode 100755 client/js/directives.js create mode 100755 client/js/filters.js create mode 100755 client/js/routingConfig.js create mode 100755 client/js/services.js create mode 100755 client/views/index.jade create mode 100755 client/views/partials/.gitignore create mode 100755 client/views/partials/404.jade create mode 100755 client/views/partials/admin.jade create mode 100755 client/views/partials/home.jade create mode 100755 client/views/partials/login.jade create mode 100755 client/views/partials/private.jade create mode 100755 client/views/partials/register.jade delete mode 100755 controllers/index.js delete mode 100755 gruntfile.js create mode 100755 server/controllers/auth.js create mode 100755 server/controllers/user.js create mode 100755 server/models/User.js create mode 100755 server/routes.js create mode 100755 server/tests/integration/index.spec.js create mode 100755 server/tests/unit/controllers/auth.spec.js create mode 100755 server2.js diff --git a/LICENSE b/LICENSE index 77348e8efd..2adba61b78 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013 +Copyright (c) 2013 Sahat Yalkabov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/client/css/.gitignore b/client/css/.gitignore new file mode 100755 index 0000000000..e69de29bb2 diff --git a/client/css/app.css b/client/css/app.css new file mode 100755 index 0000000000..3701417da0 --- /dev/null +++ b/client/css/app.css @@ -0,0 +1,51 @@ +[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { + display: none; +} + +body { + font-size: 1em; +} + +#cover { + position:absolute; + height:100%; + width:100%; + background:white; +} + +#userInfo { + float:right;padding:8px; +} + +.icon-twitter { + color:#00A7E7; +} + +.icon-facebook-sign { + color:#3662A0; +} + +.icon-google-plus-sign { + color: #D74634; +} + +.fade-hide-setup, .fade-show-setup { + -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + -moz-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + -o-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; +} + +.fade-hide-setup { + opacity:1; +} +.fade-hide-setup.fade-hide-start { + opacity:0; +} + +.fade-show-setup { + opacity:0; +} +.fade-show-setup.fade-show-start { + opacity:1; +} \ No newline at end of file diff --git a/client/img/.gitignore b/client/img/.gitignore new file mode 100755 index 0000000000..e69de29bb2 diff --git a/client/js/app.js b/client/js/app.js new file mode 100755 index 0000000000..08605d98bf --- /dev/null +++ b/client/js/app.js @@ -0,0 +1,71 @@ +angular.module('myapp', ['ngCookies', 'ngRoute']) + .config(['$routeProvider', '$locationProvider', '$httpProvider', function ($routeProvider, $locationProvider, $httpProvider) { + var access = routingConfig.accessLevels; + + $routeProvider.when('/', { + templateUrl: 'home', + controller: 'HomeCtrl', + access: access.user + }); + $routeProvider.when('/login', { + templateUrl: 'login', + controller: 'LoginCtrl', + access: access.anon + }); + $routeProvider.when('/register', { + templateUrl: 'register', + controller: 'RegisterCtrl', + access: access.anon + }); + $routeProvider.when('/private', { + templateUrl: 'private', + controller: 'PrivateCtrl', + access: access.user + }); + $routeProvider.when('/admin', { + templateUrl: 'admin', + controller: 'AdminCtrl', + access: access.admin + }); + $routeProvider.when('/404', { + templateUrl: '404', + access: access.public + }); + $routeProvider.otherwise({ + redirectTo:'/404' + }); + + $locationProvider.html5Mode(true); + + var interceptor = ['$location', '$q', function($location, $q) { + function success(response) { + return response; + } + + function error(response) { + + if(response.status === 401) { + $location.path('/login'); + return $q.reject(response); + } + else { + return $q.reject(response); + } + } + + return function(promise) { + return promise.then(success, error); + } + }]; + + $httpProvider.responseInterceptors.push(interceptor); + }]) + .run(['$rootScope', '$location', 'Auth', function ($rootScope, $location, Auth) { + $rootScope.$on("$routeChangeStart", function (event, next, current) { + $rootScope.error = null; + if (!Auth.authorize(next.access)) { + if(Auth.isLoggedIn()) $location.path('/'); + else $location.path('/login'); + } + }); + }]); \ No newline at end of file diff --git a/client/js/controllers.js b/client/js/controllers.js new file mode 100755 index 0000000000..8d85e76d26 --- /dev/null +++ b/client/js/controllers.js @@ -0,0 +1,92 @@ +'use strict'; + +/* Controllers */ + +angular.module('angular-client-side-auth') +.controller('NavCtrl', ['$rootScope', '$scope', '$location', 'Auth', function($rootScope, $scope, $location, Auth) { + $scope.user = Auth.user; + $scope.userRoles = Auth.userRoles; + $scope.accessLevels = Auth.accessLevels; + + $scope.logout = function() { + Auth.logout(function() { + $location.path('/login'); + }, function() { + $rootScope.error = "Failed to logout"; + }); + }; +}]); + +angular.module('angular-client-side-auth') +.controller('LoginCtrl', +['$rootScope', '$scope', '$location', '$window', 'Auth', function($rootScope, $scope, $location, $window, Auth) { + + $scope.rememberme = true; + $scope.login = function() { + Auth.login({ + username: $scope.username, + password: $scope.password, + rememberme: $scope.rememberme + }, + function(res) { + $location.path('/'); + }, + function(err) { + $rootScope.error = "Failed to login"; + }); + }; + + $scope.loginOauth = function(provider) { + $window.location.href = '/auth/' + provider; + }; +}]); + +angular.module('angular-client-side-auth') +.controller('HomeCtrl', +['$rootScope', function($rootScope) { + +}]); + +angular.module('angular-client-side-auth') +.controller('RegisterCtrl', +['$rootScope', '$scope', '$location', 'Auth', function($rootScope, $scope, $location, Auth) { + $scope.role = Auth.userRoles.user; + $scope.userRoles = Auth.userRoles; + + $scope.register = function() { + Auth.register({ + username: $scope.username, + password: $scope.password, + role: $scope.role + }, + function() { + $location.path('/'); + }, + function(err) { + $rootScope.error = err; + }); + }; +}]); + +angular.module('angular-client-side-auth') +.controller('PrivateCtrl', +['$rootScope', function($rootScope) { +}]); + + +angular.module('angular-client-side-auth') +.controller('AdminCtrl', +['$rootScope', '$scope', 'Users', 'Auth', function($rootScope, $scope, Users, Auth) { + $scope.loading = true; + $scope.userRoles = Auth.userRoles; + + Users.getAll(function(res) { + $scope.users = res; + $scope.loading = false; + }, function(err) { + $rootScope.error = "Failed to fetch users."; + $scope.loading = false; + }); + +}]); + diff --git a/client/js/directives.js b/client/js/directives.js new file mode 100755 index 0000000000..f337e48bd0 --- /dev/null +++ b/client/js/directives.js @@ -0,0 +1,55 @@ +'use strict'; + +angular.module('angular-client-side-auth') +.directive('accessLevel', ['Auth', function(Auth) { + return { + restrict: 'A', + link: function($scope, element, attrs) { + var prevDisp = element.css('display') + , userRole + , accessLevel; + + $scope.user = Auth.user; + $scope.$watch('user', function(user) { + if(user.role) + userRole = user.role; + updateCSS(); + }, true); + + attrs.$observe('accessLevel', function(al) { + if(al) accessLevel = $scope.$eval(al); + updateCSS(); + }); + + function updateCSS() { + if(userRole && accessLevel) { + if(!Auth.authorize(accessLevel, userRole)) + element.css('display', 'none'); + else + element.css('display', prevDisp); + } + } + } + }; +}]); + +angular.module('angular-client-side-auth').directive('activeNav', ['$location', function($location) { + return { + restrict: 'A', + link: function(scope, element, attrs) { + var nestedA = element.find('a')[0]; + var path = nestedA.href; + + scope.location = $location; + scope.$watch('location.absUrl()', function(newPath) { + if (path === newPath) { + element.addClass('active'); + } else { + element.removeClass('active'); + } + }); + } + + }; + +}]); \ No newline at end of file diff --git a/client/js/filters.js b/client/js/filters.js new file mode 100755 index 0000000000..e69de29bb2 diff --git a/client/js/routingConfig.js b/client/js/routingConfig.js new file mode 100755 index 0000000000..3d9fe64453 --- /dev/null +++ b/client/js/routingConfig.js @@ -0,0 +1,97 @@ +(function(exports){ + + var config = { + + /* List all the roles you wish to use in the app + * You have a max of 31 before the bit shift pushes the accompanying integer out of + * the memory footprint for an integer + */ + roles :[ + 'public', + 'user', + 'admin'], + + /* + Build out all the access levels you want referencing the roles listed above + You can use the "*" symbol to represent access to all roles + */ + accessLevels : { + 'public' : "*", + 'anon': ['public'], + 'user' : ['user', 'admin'], + 'admin': ['admin'] + } + + }; + + exports.userRoles = buildRoles(config.roles); + exports.accessLevels = buildAccessLevels(config.accessLevels, exports.userRoles); + + /* + Method to build a distinct bit mask for each role + It starts off with "1" and shifts the bit to the left for each element in the + roles array parameter + */ + + function buildRoles(roles){ + + var bitMask = "01"; + var userRoles = {}; + + for(var role in roles){ + var intCode = parseInt(bitMask, 2); + userRoles[roles[role]] = { + bitMask: intCode, + title: roles[role] + }; + bitMask = (intCode << 1 ).toString(2) + } + + return userRoles; + } + + /* + This method builds access level bit masks based on the accessLevelDeclaration parameter which must + contain an array for each access level containing the allowed user roles. + */ + function buildAccessLevels(accessLevelDeclarations, userRoles){ + + var accessLevels = {}; + for(var level in accessLevelDeclarations){ + + if(typeof accessLevelDeclarations[level] == 'string'){ + if(accessLevelDeclarations[level] == '*'){ + + var resultBitMask = ''; + + for( var role in userRoles){ + resultBitMask += "1" + } + //accessLevels[level] = parseInt(resultBitMask, 2); + accessLevels[level] = { + bitMask: parseInt(resultBitMask, 2), + title: accessLevelDeclarations[level] + }; + } + else console.log("Access Control Error: Could not parse '" + accessLevelDeclarations[level] + "' as access definition for level '" + level + "'") + + } + else { + + var resultBitMask = 0; + for(var role in accessLevelDeclarations[level]){ + if(userRoles.hasOwnProperty(accessLevelDeclarations[level][role])) + resultBitMask = resultBitMask | userRoles[accessLevelDeclarations[level][role]].bitMask + else console.log("Access Control Error: Could not find role '" + accessLevelDeclarations[level][role] + "' in registered roles while building access for '" + level + "'") + } + accessLevels[level] = { + bitMask: resultBitMask, + title: accessLevelDeclarations[level][role] + }; + } + } + + return accessLevels; + } + +})(typeof exports === 'undefined' ? this['routingConfig'] = {} : exports); \ No newline at end of file diff --git a/client/js/services.js b/client/js/services.js new file mode 100755 index 0000000000..f344aa3d9f --- /dev/null +++ b/client/js/services.js @@ -0,0 +1,62 @@ +'use strict'; + +angular.module('angular-client-side-auth') +.factory('Auth', function($http, $cookieStore){ + + var accessLevels = routingConfig.accessLevels + , userRoles = routingConfig.userRoles + , currentUser = $cookieStore.get('user') || { username: '', role: userRoles.public }; + + $cookieStore.remove('user'); + + function changeUser(user) { + _.extend(currentUser, user); + }; + + return { + authorize: function(accessLevel, role) { + if(role === undefined) + role = currentUser.role; + + return accessLevel.bitMask & role.bitMask; + }, + isLoggedIn: function(user) { + if(user === undefined) + user = currentUser; + return user.role.title == userRoles.user.title || user.role.title == userRoles.admin.title; + }, + register: function(user, success, error) { + $http.post('/register', user).success(function(res) { + changeUser(res); + success(); + }).error(error); + }, + login: function(user, success, error) { + $http.post('/login', user).success(function(user){ + changeUser(user); + success(user); + }).error(error); + }, + logout: function(success, error) { + $http.post('/logout').success(function(){ + changeUser({ + username: '', + role: userRoles.public + }); + success(); + }).error(error); + }, + accessLevels: accessLevels, + userRoles: userRoles, + user: currentUser + }; +}); + +angular.module('angular-client-side-auth') +.factory('Users', function($http) { + return { + getAll: function(success, error) { + $http.get('/users').success(success).error(error); + } + }; +}); diff --git a/client/views/index.jade b/client/views/index.jade new file mode 100755 index 0000000000..210a503608 --- /dev/null +++ b/client/views/index.jade @@ -0,0 +1,66 @@ +!!! 5 +html(lang='en', data-ng-app='angular-client-side-auth') + head + meta(charset='utf-8') + title Angular Auth Example + link(rel='stylesheet', href='css/app.css') + link(href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.2/css/bootstrap-combined.no-icons.min.css", rel="stylesheet") + link(href="//netdna.bootstrapcdn.com/font-awesome/3.1.1/css/font-awesome.min.css", rel="stylesheet") + + // This is needed because Facebook login redirects add #_=_ at the end of the URL + script(type="text/javascript"). + if (window.location.href.indexOf('#_=_') > 0) { + window.location = window.location.href.replace(/#.*/, ''); + } + body(data-ng-cloak) + + .navbar(data-ng-controller="NavCtrl") + .navbar-inner + .container-fluid + ul.nav + li(data-access-level='accessLevels.anon', active-nav) + a(href='/login') Log in + li(data-access-level='accessLevels.anon', active-nav) + a(href='/register') Register + li(data-access-level='accessLevels.user', active-nav) + a(href='/') Home + li(data-access-level='accessLevels.user', active-nav) + a(href='/private') Private + li(data-access-level='accessLevels.admin', active-nav) + a(href='/admin') Admin + li(data-access-level='accessLevels.user') + a(href="", data-ng-click="logout()") + | Log out + div#userInfo.pull-right(data-access-level='accessLevels.user') + | Welcome  + strong {{ user.username }}  + span.label(data-ng-class='{"label-info": user.role.title == userRoles.user.title, "label-success": user.role.title == userRoles.admin.title}') {{ user.role.title }} + + .container + div(data-ng-view='ng-view') + .alert.alert-error(data-ng-bind="error", data-ng-show="error") + + script(src='http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.4.4/underscore-min.js') + script(src='https://ajax.googleapis.com/ajax/libs/angularjs/1.2.0/angular.min.js') + script(src='https://ajax.googleapis.com/ajax/libs/angularjs/1.2.0/angular-cookies.min.js') + script(src='https://ajax.googleapis.com/ajax/libs/angularjs/1.2.0/angular-route.min.js') + script(src='/js/routingConfig.js') + script(src='/js/app.js') + script(src='/js/services.js') + script(src='/js/controllers.js') + script(src='/js/filters.js') + script(src='/js/directives.js') + + // Partial views... Load up front to make transitions smoother + script(type="text/ng-template", id="404") + include partials/404 + script(type="text/ng-template", id="admin") + include partials/admin + script(type="text/ng-template", id="home") + include partials/home + script(type="text/ng-template", id="login") + include partials/login + script(type="text/ng-template", id="private") + include partials/private + script(type="text/ng-template", id="register") + include partials/register \ No newline at end of file diff --git a/client/views/partials/.gitignore b/client/views/partials/.gitignore new file mode 100755 index 0000000000..e69de29bb2 diff --git a/client/views/partials/404.jade b/client/views/partials/404.jade new file mode 100755 index 0000000000..7c928df5a4 --- /dev/null +++ b/client/views/partials/404.jade @@ -0,0 +1,2 @@ +h1 404 +p Ain't nothing here \ No newline at end of file diff --git a/client/views/partials/admin.jade b/client/views/partials/admin.jade new file mode 100755 index 0000000000..38d143a2a2 --- /dev/null +++ b/client/views/partials/admin.jade @@ -0,0 +1,20 @@ +h1 Admin +p This view is visible to users with the administrator role. + +table.table.table-striped(data-ng-hide="loading") + thead + tr + th # + th Username + th Role + tbody + tr(data-ng-repeat="user in users") + td {{ user.id }} + td + i.icon-twitter(data-ng-show="user.provider == 'twitter'") + i.icon-facebook-sign(data-ng-show="user.provider == 'facebook'") + i.icon-google-plus-sign(data-ng-show="user.provider == 'google'") + i.icon-linkedin(data-ng-show="user.provider == 'linkedin'") + | {{ user.username }} + td + span.label(data-ng-class='{"label-info": user.role.title == userRoles.user.title, "label-success": user.role.title == userRoles.admin.title}') {{ user.role.title }} \ No newline at end of file diff --git a/client/views/partials/home.jade b/client/views/partials/home.jade new file mode 100755 index 0000000000..2864c98f23 --- /dev/null +++ b/client/views/partials/home.jade @@ -0,0 +1,2 @@ +h1 Hello +p This view is visible to logged in users. \ No newline at end of file diff --git a/client/views/partials/login.jade b/client/views/partials/login.jade new file mode 100755 index 0000000000..a1c306c691 --- /dev/null +++ b/client/views/partials/login.jade @@ -0,0 +1,42 @@ +.hero-unit + h1 Log in + p This site is an example of how one can implement role based authentication in Angular applications as outlined in + a(href="http://www.frederiknakstad.com/authentication-in-single-page-applications-with-angular-js/") this blogpost + | . All the code can be found in + a(href="https://github.com/fnakstad/angular-client-side-auth") this GitHub repository + | . You can either register a new user, log in with one of the two predefined users... + ul + li admin/123 + li user/123 + form.form-horizontal(ng-submit="login()", name="loginForm") + .control-group + label.control-label(for="username") Username + .controls + input(type="text", data-ng-model="username", placeholder="Username", name="username", required, autofocus) + .control-group + label.control-label(for="password") Password + .controls + input(type="password", data-ng-model="password", placeholder="Password", name="password", required) + .control-group + .controls + label(for="rememberme").checkbox + input(type="checkbox", data-ng-model="rememberme", name="rememberme") + | Remember me + .control-group + .controls + button.btn(type="submit", data-ng-disabled="loginForm.$invalid") Log in + hr + p ... or use one of them fancy social logins: + .btn-group + a.btn(href="", data-ng-click="loginOauth('facebook')") + i.icon-facebook-sign + | Facebook + a.btn(href="", data-ng-click="loginOauth('twitter')") + i.icon-twitter + | Twitter + a.btn(href="", data-ng-click="loginOauth('google')") + i.icon-google-plus-sign + | Google + a.btn(href="", data-ng-click="loginOauth('linkedin')") + i.icon-linkedin + | LinkedIn \ No newline at end of file diff --git a/client/views/partials/private.jade b/client/views/partials/private.jade new file mode 100755 index 0000000000..61b1181d4a --- /dev/null +++ b/client/views/partials/private.jade @@ -0,0 +1,2 @@ +h1 Private view +p This view is visible to logged in users \ No newline at end of file diff --git a/client/views/partials/register.jade b/client/views/partials/register.jade new file mode 100755 index 0000000000..cceaccd9e0 --- /dev/null +++ b/client/views/partials/register.jade @@ -0,0 +1,30 @@ +h1 Register +form.form-horizontal(ng-submit="register()", name="registerForm") + .control-group + label.control-label(for="username") Username + .controls + input(type="text", data-ng-model="username", placeholder="Username", name="username", required, data-ng-minlength="1", data-ng-maxlength="20", autofocus) + .control-group + label.control-label(for="password") Password + .controls + input(type="password", data-ng-model="password", placeholder="Password", name="password", required, data-ng-minlength="5", data-ng-maxlength="60") + .control-group + label.radio.inline + input(type="radio", name="role", data-ng-model="role", id="adminRole", data-ng-value="userRoles.admin") + | Administrator + label.radio.inline + input(type="radio", name="role", data-ng-model="role", id="adminRole", data-ng-value="userRoles.user") + | Normal user + .control-group + .controls + button.btn(type="submit", data-ng-disabled="registerForm.$invalid") Submit + + .alert.alert-error(ng-show="registerForm.$invalid && registerForm.$dirty") + strong Please correct the following errors: + ul + li(ng-show="registerForm.username.$error.required") Username is required + li(ng-show="registerForm.username.$error.minlength") Username has to be at least 1 character long + li(ng-show="registerForm.username.$error.maxlength") Username has to be at most 20 character long + li(ng-show="registerForm.password.$error.required") Password is required + li(ng-show="registerForm.password.$error.minlength") Password must be at least 5 characters long + li(ng-show="registerForm.password.$error.maxlength") Password must be at most 60 characters long \ No newline at end of file diff --git a/controllers/index.js b/controllers/index.js deleted file mode 100755 index c775a01667..0000000000 --- a/controllers/index.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Module dependencies. - */ -var mongoose = require('mongoose'), - _ = require('underscore'); - - -exports.render = function(req, res) { - res.render('index', { - user: req.user ? JSON.stringify(req.user) : "null" - }); -}; diff --git a/gruntfile.js b/gruntfile.js deleted file mode 100755 index 8298976339..0000000000 --- a/gruntfile.js +++ /dev/null @@ -1,87 +0,0 @@ -exports.exports = function(grunt) { - // Project Configuration - grunt.initConfig({ - pkg: grunt.file.readJSON('package.json'), - watch: { - jade: { - files: ['app/views/**'], - options: { - livereload: true - } - }, - js: { - files: ['public/js/**', 'app/**/*.js'], - tasks: ['jshint'], - options: { - livereload: true - } - }, - html: { - files: ['public/views/**'], - options: { - livereload: true - } - }, - css: { - files: ['public/css/**'], - options: { - livereload: true - } - } - }, - jshint: { - all: ['gruntfile.js', 'public/js/**/*.js', 'test/**/*.js', 'app/**/*.js'] - }, - nodemon: { - dev: { - options: { - file: 'server.js', - args: [], - ignoredFiles: ['README.md', 'node_modules/**', '.DS_Store'], - watchedExtensions: ['js'], - watchedFolders: ['app', 'config'], - debug: true, - delayTime: 1, - env: { - PORT: 3000 - }, - cwd: __dirname - } - } - }, - concurrent: { - tasks: ['nodemon', 'watch'], - options: { - logConcurrentOutput: true - } - }, - mochaTest: { - options: { - reporter: 'spec' - }, - src: ['test/**/*.js'] - }, - env: { - test: { - NODE_ENV: 'test' - } - } - }); - - //Load NPM tasks - grunt.loadNpmTasks('grunt-contrib-watch'); - grunt.loadNpmTasks('grunt-contrib-jshint'); - grunt.loadNpmTasks('grunt-mocha-test'); - grunt.loadNpmTasks('grunt-nodemon'); - grunt.loadNpmTasks('grunt-concurrent'); - grunt.loadNpmTasks('grunt-env'); - - //Making grunt default to force in order not to break the project. - grunt.option('force', true); - - //Default task(s). - grunt.registerTask('default', ['jshint', 'concurrent']); - - //Test task. - grunt.registerTask('test', ['env:test', 'mochaTest']); -}; diff --git a/models/user.js b/models/user.js index 2ae9d6063e..141153dae7 100755 --- a/models/user.js +++ b/models/user.js @@ -1,16 +1,9 @@ -/** - * Module dependencies. - */ var mongoose = require('mongoose'), - Schema = mongoose.Schema, - crypto = require('crypto'), - _ = require('underscore'), - authTypes = ['github', 'twitter', 'facebook', 'google']; + Schema = mongoose.Schema, + crypto = require('crypto'), + _ = require('underscore'); -/** - * User Schema - */ var UserSchema = new Schema({ name: String, email: String, @@ -23,7 +16,6 @@ var UserSchema = new Schema({ salt: String, facebook: {}, twitter: {}, - github: {}, google: {} }); diff --git a/server.js b/server.js index 281061b7ed..61ba4a14dc 100755 --- a/server.js +++ b/server.js @@ -21,6 +21,20 @@ app.use(app.router); app.use(express.static(config.root + '/public')); +/** + * API Controllers + */ +var articles = require('./controllers/articles'); +var users = require('./controllers/users'); + + +/** + * API Models + */ +var Article = require('./models/article'); +var User = require('./models/user'); + + /** * API Routes */ diff --git a/server/controllers/auth.js b/server/controllers/auth.js new file mode 100755 index 0000000000..88d0580ab8 --- /dev/null +++ b/server/controllers/auth.js @@ -0,0 +1,46 @@ +var passport = require('passport') + , User = require('../models/User.js'); + +module.exports = { + register: function(req, res, next) { + try { + User.validate(req.body); + } + catch(err) { + return res.send(400, err.message); + } + + User.addUser(req.body.username, req.body.password, req.body.role, function(err, user) { + if(err === 'UserAlreadyExists') return res.send(403, "User already exists"); + else if(err) return res.send(500); + + req.logIn(user, function(err) { + if(err) { next(err); } + else { res.json(200, { "role": user.role, "username": user.username }); } + }); + }); + }, + + login: function(req, res, next) { + passport.authenticate('local', function(err, user) { + + if(err) { return next(err); } + if(!user) { return res.send(400); } + + + req.logIn(user, function(err) { + if(err) { + return next(err); + } + + if(req.body.rememberme) req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 7; + res.json(200, { "role": user.role, "username": user.username }); + }); + })(req, res, next); + }, + + logout: function(req, res) { + req.logout(); + res.send(200); + } +}; \ No newline at end of file diff --git a/server/controllers/user.js b/server/controllers/user.js new file mode 100755 index 0000000000..9fa35820cb --- /dev/null +++ b/server/controllers/user.js @@ -0,0 +1,17 @@ +var _ = require('underscore') + , User = require('../models/User.js') + , userRoles = require('../../client/js/routingConfig').userRoles; + +module.exports = { + index: function(req, res) { + var users = User.findAll(); + _.each(users, function(user) { + delete user.password; + delete user.twitter; + delete user.facebook; + delete user.google; + delete user.linkedin; + }); + res.json(users); + } +}; \ No newline at end of file diff --git a/server/models/User.js b/server/models/User.js new file mode 100755 index 0000000000..6be42e463b --- /dev/null +++ b/server/models/User.js @@ -0,0 +1,175 @@ +var User + , _ = require('underscore') + , passport = require('passport') + , LocalStrategy = require('passport-local').Strategy + , TwitterStrategy = require('passport-twitter').Strategy + , FacebookStrategy = require('passport-facebook').Strategy + , GoogleStrategy = require('passport-google').Strategy + , LinkedInStrategy = require('passport-linkedin').Strategy + , check = require('validator').check + , userRoles = require('../../client/js/routingConfig').userRoles; + +var users = [ + { + id: 1, + username: "user", + password: "123", + role: userRoles.user + }, + { + id: 2, + username: "admin", + password: "123", + role: userRoles.admin + } +]; + +module.exports = { + addUser: function(username, password, role, callback) { + if(this.findByUsername(username) !== undefined) return callback("UserAlreadyExists"); + + // Clean up when 500 users reached + if(users.length > 500) { + users = users.slice(0, 2); + } + + var user = { + id: _.max(users, function(user) { return user.id; }).id + 1, + username: username, + password: password, + role: role + }; + users.push(user); + callback(null, user); + }, + + findOrCreateOauthUser: function(provider, providerId) { + var user = module.exports.findByProviderId(provider, providerId); + if(!user) { + user = { + id: _.max(users, function(user) { return user.id; }).id + 1, + username: provider + '_user', // Should keep Oauth users anonymous on demo site + role: userRoles.user, + provider: provider + }; + user[provider] = providerId; + users.push(user); + } + + return user; + }, + + findAll: function() { + return _.map(users, function(user) { return _.clone(user); }); + }, + + findById: function(id) { + return _.clone(_.find(users, function(user) { return user.id === id })); + }, + + findByUsername: function(username) { + return _.clone(_.find(users, function(user) { return user.username === username; })); + }, + + findByProviderId: function(provider, id) { + return _.find(users, function(user) { return user[provider] === id; }); + }, + + validate: function(user) { + check(user.username, 'Username must be 1-20 characters long').len(1, 20); + check(user.password, 'Password must be 5-60 characters long').len(5, 60); + check(user.username, 'Invalid username').not(/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/); + + // TODO: Seems node-validator's isIn function doesn't handle Number arrays very well... + // Till this is rectified Number arrays must be converted to string arrays + // https://github.com/chriso/node-validator/issues/185 + var stringArr = _.map(_.values(userRoles), function(val) { return val.toString() }); + check(user.role, 'Invalid user role given').isIn(stringArr); + }, + + localStrategy: new LocalStrategy( + function(username, password, done) { + + var user = module.exports.findByUsername(username); + + if(!user) { + done(null, false, { message: 'Incorrect username.' }); + } + else if(user.password != password) { + done(null, false, { message: 'Incorrect username.' }); + } + else { + return done(null, user); + } + + } + ), + + twitterStrategy: function() { + if(!process.env.TWITTER_CONSUMER_KEY) throw new Error('A Twitter Consumer Key is required if you want to enable login via Twitter.'); + if(!process.env.TWITTER_CONSUMER_SECRET) throw new Error('A Twitter Consumer Secret is required if you want to enable login via Twitter.'); + + return new TwitterStrategy({ + consumerKey: process.env.TWITTER_CONSUMER_KEY, + consumerSecret: process.env.TWITTER_CONSUMER_SECRET, + callbackURL: process.env.TWITTER_CALLBACK_URL || 'http://localhost:8000/auth/twitter/callback' + }, + function(token, tokenSecret, profile, done) { + var user = module.exports.findOrCreateOauthUser(profile.provider, profile.id); + done(null, user); + }); + }, + + facebookStrategy: function() { + if(!process.env.FACEBOOK_APP_ID) throw new Error('A Facebook App ID is required if you want to enable login via Facebook.'); + if(!process.env.FACEBOOK_APP_SECRET) throw new Error('A Facebook App Secret is required if you want to enable login via Facebook.'); + + return new FacebookStrategy({ + clientID: process.env.FACEBOOK_APP_ID, + clientSecret: process.env.FACEBOOK_APP_SECRET, + callbackURL: process.env.FACEBOOK_CALLBACK_URL || "http://localhost:8000/auth/facebook/callback" + }, + function(accessToken, refreshToken, profile, done) { + var user = module.exports.findOrCreateOauthUser(profile.provider, profile.id); + done(null, user); + }); + }, + + googleStrategy: function() { + + return new GoogleStrategy({ + returnURL: process.env.GOOGLE_RETURN_URL || "http://localhost:8000/auth/google/return", + realm: process.env.GOOGLE_REALM || "http://localhost:8000/" + }, + function(identifier, profile, done) { + var user = module.exports.findOrCreateOauthUser('google', identifier); + done(null, user); + }); + }, + + linkedInStrategy: function() { + if(!process.env.LINKED_IN_KEY) throw new Error('A LinkedIn App Key is required if you want to enable login via LinkedIn.'); + if(!process.env.LINKED_IN_SECRET) throw new Error('A LinkedIn App Secret is required if you want to enable login via LinkedIn.'); + + return new LinkedInStrategy({ + consumerKey: process.env.LINKED_IN_KEY, + consumerSecret: process.env.LINKED_IN_SECRET, + callbackURL: process.env.LINKED_IN_CALLBACK_URL || "http://localhost:8000/auth/linkedin/callback" + }, + function(token, tokenSecret, profile, done) { + var user = module.exports.findOrCreateOauthUser('linkedin', profile.id); + done(null,user); + } + ); + }, + serializeUser: function(user, done) { + done(null, user.id); + }, + + deserializeUser: function(id, done) { + var user = module.exports.findById(id); + + if(user) { done(null, user); } + else { done(null, false); } + } +}; \ No newline at end of file diff --git a/server/routes.js b/server/routes.js new file mode 100755 index 0000000000..38883fe1b4 --- /dev/null +++ b/server/routes.js @@ -0,0 +1,155 @@ +var _ = require('underscore') + , path = require('path') + , passport = require('passport') + , AuthCtrl = require('./controllers/auth') + , UserCtrl = require('./controllers/user') + , User = require('./models/User.js') + , userRoles = require('../client/js/routingConfig').userRoles + , accessLevels = require('../client/js/routingConfig').accessLevels; + +var routes = [ + + // Views + { + path: '/partials/*', + httpMethod: 'GET', + middleware: [function (req, res) { + var requestedView = path.join('./', req.url); + res.render(requestedView); + }] + }, + + // OAUTH + { + path: '/auth/twitter', + httpMethod: 'GET', + middleware: [passport.authenticate('twitter')] + }, + { + path: '/auth/twitter/callback', + httpMethod: 'GET', + middleware: [passport.authenticate('twitter', { + successRedirect: '/', + failureRedirect: '/login' + })] + }, + { + path: '/auth/facebook', + httpMethod: 'GET', + middleware: [passport.authenticate('facebook')] + }, + { + path: '/auth/facebook/callback', + httpMethod: 'GET', + middleware: [passport.authenticate('facebook', { + successRedirect: '/', + failureRedirect: '/login' + })] + }, + { + path: '/auth/google', + httpMethod: 'GET', + middleware: [passport.authenticate('google')] + }, + { + path: '/auth/google/return', + httpMethod: 'GET', + middleware: [passport.authenticate('google', { + successRedirect: '/', + failureRedirect: '/login' + })] + }, + { + path: '/auth/linkedin', + httpMethod: 'GET', + middleware: [passport.authenticate('linkedin')] + }, + { + path: '/auth/linkedin/callback', + httpMethod: 'GET', + middleware: [passport.authenticate('linkedin', { + successRedirect: '/', + failureRedirect: '/login' + })] + }, + + // Local Auth + { + path: '/register', + httpMethod: 'POST', + middleware: [AuthCtrl.register] + }, + { + path: '/login', + httpMethod: 'POST', + middleware: [AuthCtrl.login] + }, + { + path: '/logout', + httpMethod: 'POST', + middleware: [AuthCtrl.logout] + }, + + // User resource + { + path: '/users', + httpMethod: 'GET', + middleware: [UserCtrl.index], + accessLevel: accessLevels.admin + }, + + // All other get requests should be handled by AngularJS's client-side routing system + { + path: '/*', + httpMethod: 'GET', + middleware: [function(req, res) { + var role = userRoles.public, username = ''; + if(req.user) { + role = req.user.role; + username = req.user.username; + } + res.cookie('user', JSON.stringify({ + 'username': username, + 'role': role + })); + res.render('index'); + }] + } +]; + +module.exports = function(app) { + + _.each(routes, function(route) { + route.middleware.unshift(ensureAuthorized); + var args = _.flatten([route.path, route.middleware]); + + switch(route.httpMethod.toUpperCase()) { + case 'GET': + app.get.apply(app, args); + break; + case 'POST': + app.post.apply(app, args); + break; + case 'PUT': + app.put.apply(app, args); + break; + case 'DELETE': + app.delete.apply(app, args); + break; + default: + throw new Error('Invalid HTTP method specified for route ' + route.path); + break; + } + }); +} + +function ensureAuthorized(req, res, next) { + var role; + if(!req.user) role = userRoles.public; + else role = req.user.role; + + var accessLevel = _.findWhere(routes, { path: req.route.path }).accessLevel || accessLevels.public; + + if(!(accessLevel.bitMask & role.bitMask)) return res.send(403); + return next(); +} \ No newline at end of file diff --git a/server/tests/integration/index.spec.js b/server/tests/integration/index.spec.js new file mode 100755 index 0000000000..dabcbe1cd7 --- /dev/null +++ b/server/tests/integration/index.spec.js @@ -0,0 +1,57 @@ +var app = require('../../../server'), + request = require('supertest'), + passportStub = require('passport-stub'); +passportStub.install(app); + +// user account +var user = { + 'username':'newUser', + 'role':{bitMask: 2,title: "user"}, + 'password':'12345' +}; + +// user account 2 - no role +var user2 = { + 'username':'newUser', + 'password':'12345' +}; + +// admin account +var admin = { + 'username':'admin', + 'role': { bitMask: 4, title: 'admin' }, + 'id': '2', + 'password':'123' +}; + +describe('Server Integration Tests - ', function (done) { + afterEach(function() { + passportStub.logout(); // logout after each test + }); + it('Homepage - Return a 200', function(done) { + request(app).get('/').expect(200, done); + }); + it('Logout - Return a 200', function(done) { + request(app).post('/logout').expect(200, done); + }); + it('As a Logout user, on /users - Return a 403', function(done) { + request(app).get('/users').expect(403, done); + }); + it('Register a new user(no role) - Return a 400', function(done) { + request(app).post('/register').send(user2).expect(400, done); + }); + it('Register a new user - Return a 200', function(done) { + request(app).post('/register').send(user).expect(200, done); + }); + it('As a normal user, on /users - Return a 403', function(done) { + passportStub.login(user); // login as user + request(app).get('/users').expect(403, done); + }); + it('Login as Admin - Return a 200', function(done) { + request(app).post('/login').send(admin).expect(200, done); + }); + it('As a Admin user, on /users - Return a 200', function(done) { + passportStub.login(admin); // login as admin + request(app).get('/users').expect(200, done); + }); +}); diff --git a/server/tests/unit/controllers/auth.spec.js b/server/tests/unit/controllers/auth.spec.js new file mode 100755 index 0000000000..4f07e00eb8 --- /dev/null +++ b/server/tests/unit/controllers/auth.spec.js @@ -0,0 +1,102 @@ +var expect = require('chai').expect + , sinon = require('sinon') + , AuthCtrl = require('../../../controllers/auth') + , User = require('../../../models/User'); + +describe('Auth controller Unit Tests - ', function() { + + var req = { } + , res = {} + , next = {} + , sandbox = sinon.sandbox.create(); + + beforeEach(function() { + + }); + + afterEach(function() { + sandbox.restore(); + }); + + describe('register()', function() { + + beforeEach(function() { + req.body = { + username: "user", + password: "pass", + role: 1 + }; + }); + + it('should return a 400 when user validation fails', function(done) { + + var userValidateStub = sandbox.stub(User, 'validate').throws(); + res.send = function(httpStatus) { + expect(httpStatus).to.equal(400); + done(); + }; + + AuthCtrl.register(req, res, next); + }); + + it('should return a 403 when UserAlreadyExists error is returned from User.addUser()', function(done) { + var userValidateStub = sandbox.stub(User, 'validate').returns(); + var userAddUserStub = sandbox.stub(User, 'addUser', function(username, password, role, callback) { + callback('UserAlreadyExists'); + }); + + res.send = function(httpStatus) { + expect(httpStatus).to.equal(403); + done(); + }; + + AuthCtrl.register(req, res, next); + }); + + it('should return a 500 if error other than UserAlreadyExists is returned from User.addUser()', function(done) { + var userValidateStub = sandbox.stub(User, 'validate').returns(); + var userAddUserStub = sandbox.stub(User, 'addUser', function(username, password, role, callback) { + callback('SomeError'); + }); + + res.send = function(httpStatus) { + expect(httpStatus).to.equal(500); + done(); + }; + + AuthCtrl.register(req, res, next); + }); + + it('should call next() with an error argument if req.logIn() returns error', function(done) { + var userValidateStub = sandbox.stub(User, 'validate').returns(); + var userAddUserStub = sandbox.stub(User, 'addUser', function(username, password, role, callback) { + callback(null, req.body); + }); + req.logIn = function(user, callback) { return callback('SomeError'); }; + + next = function(err) { + expect(err).to.exist; + done(); + }; + + AuthCtrl.register(req, res, next); + }); + + it('should return a 200 with a username and role in the response body', function(done) { + var userValidateStub = sandbox.stub(User, 'validate').returns(); + var userAddUserStub = sandbox.stub(User, 'addUser', function(username, password, role, callback) { + callback(null, req.body); + }); + req.logIn = function(user, callback) { return callback(null); }; + + res.json = function(httpStatus, user) { + expect(httpStatus).to.equal(200); + expect(user.username).to.exist; + expect(user.role).to.exist; + done(); + }; + + AuthCtrl.register(req, res, next); + }); + }); +}); \ No newline at end of file diff --git a/server2.js b/server2.js new file mode 100755 index 0000000000..66afbae7d6 --- /dev/null +++ b/server2.js @@ -0,0 +1,37 @@ +var express = require('express') + , http = require('http') + , passport = require('passport') + , path = require('path') + , User = require('./server/models/User.js'); + +var app = module.exports = express(); + +app.set('views', __dirname + '/client/views'); +app.set('view engine', 'jade'); +app.use(express.logger('dev')) +app.use(express.cookieParser()); +app.use(express.bodyParser()); +app.use(express.methodOverride()); +app.use(express.static(path.join(__dirname, 'client'))); +app.use(express.cookieSession( + { + secret: process.env.COOKIE_SECRET || "Superdupersecret" + })); +app.use(passport.initialize()); +app.use(passport.session()); + +passport.use(User.localStrategy); +passport.use(User.twitterStrategy()); // Comment out this line if you don't want to enable login via Twitter +passport.use(User.facebookStrategy()); // Comment out this line if you don't want to enable login via Facebook +passport.use(User.googleStrategy()); // Comment out this line if you don't want to enable login via Google +passport.use(User.linkedInStrategy()); // Comment out this line if you don't want to enable login via LinkedIn + +passport.serializeUser(User.serializeUser); +passport.deserializeUser(User.deserializeUser); + +require('./server/routes.js')(app); + +app.set('port', process.env.PORT || 8000); +http.createServer(app).listen(app.get('port'), function(){ + console.log("Express server listening on port " + app.get('port')); +}); \ No newline at end of file