added sample app for client-server authentication

This commit is contained in:
Sahat Yalkabov
2013-11-13 17:16:06 -05:00
parent bf83154066
commit fc5563c70c
29 changed files with 1199 additions and 111 deletions

View File

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2013 <Author>
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

0
client/css/.gitignore vendored Executable file
View File

51
client/css/app.css Executable file
View File

@ -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;
}

0
client/img/.gitignore vendored Executable file
View File

71
client/js/app.js Executable file
View File

@ -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');
}
});
}]);

92
client/js/controllers.js Executable file
View File

@ -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;
});
}]);

55
client/js/directives.js Executable file
View File

@ -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');
}
});
}
};
}]);

0
client/js/filters.js Executable file
View File

97
client/js/routingConfig.js Executable file
View File

@ -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);

62
client/js/services.js Executable file
View File

@ -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);
}
};
});

66
client/views/index.jade Executable file
View File

@ -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&nbsp;
strong {{ user.username }}&nbsp;
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

0
client/views/partials/.gitignore vendored Executable file
View File

2
client/views/partials/404.jade Executable file
View File

@ -0,0 +1,2 @@
h1 404
p Ain't nothing here

View File

@ -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 }}

View File

@ -0,0 +1,2 @@
h1 Hello
p This view is visible to logged in users.

View File

@ -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

View File

@ -0,0 +1,2 @@
h1 Private view
p This view is visible to logged in users

View File

@ -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

View File

@ -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"
});
};

View File

@ -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']);
};

View File

@ -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: {}
});

View File

@ -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
*/

46
server/controllers/auth.js Executable file
View File

@ -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);
}
};

17
server/controllers/user.js Executable file
View File

@ -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);
}
};

175
server/models/User.js Executable file
View File

@ -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); }
}
};

155
server/routes.js Executable file
View File

@ -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();
}

View File

@ -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);
});
});

View File

@ -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);
});
});
});

37
server2.js Executable file
View File

@ -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'));
});