diff --git a/app/index.js b/app/index.js index 009bbc7..f3d1bb5 100644 --- a/app/index.js +++ b/app/index.js @@ -99,13 +99,22 @@ var BangularGenerator = yeoman.generators.Base.extend({ } if (props.backend === 'mongo') { - self.prompt({ + self.prompt([{ type: 'confirm', name: 'sockets', message: 'Do you want to add socket support?', default: false - }, function (props) { + }, { + type: 'confirm', + name: 'auth', + message: 'Do you want to scaffold a passport authentication process?', + default: false + }], function (props) { self.filters.sockets = props.sockets; + self.filters.auth = props.auth; + if (props.auth) { + self.filters['ngCookies'] = true; + } done(); }); } else { diff --git a/app/templates/#.jshintrc b/app/templates/#.jshintrc index 86d4b8f..2166f80 100644 --- a/app/templates/#.jshintrc +++ b/app/templates/#.jshintrc @@ -17,7 +17,6 @@ "undef": true, "unused": true, "strict": true, - "maxparams": 3, "maxdepth": 3, "maxlen": 80, @@ -35,6 +34,8 @@ "describe": true, "inject": true, "expect": true, + "afterEach": true, + "alert": true, "beforeEach": true } } diff --git a/app/templates/client/app.js b/app/templates/client/app.js index 0264412..e7f2d13 100644 --- a/app/templates/client/app.js +++ b/app/templates/client/app.js @@ -8,13 +8,45 @@ angular.module('<%= appname %>', [ 'ngAnimate'<% } %><% if (filters.sockets) { %>, 'btford.socket-io'<% } %> ]) - .config(function ($routeProvider, $locationProvider) { + .config(function ($routeProvider, $locationProvider<% if (filters.auth) { %>, $httpProvider<% } %>) { $routeProvider .otherwise({ redirectTo: '/' }); - $locationProvider.html5Mode(true); + $locationProvider.html5Mode(true);<% if (filters.auth) { %> + $httpProvider.interceptors.push('authInterceptor');<% } %> - }); + })<% if (filters.auth) { %> + .factory('authInterceptor', + function ($rootScope, $q, $cookieStore, $location) { + return { + + request: function (config) { + config.headers = config.headers || {}; + if ($cookieStore.get('token')) { + config.headers.Authorization = 'Bearer ' + $cookieStore.get('token'); + } + return config; + }, + + responseError: function (response) { + if (response.status === 401) { + $location.path('/login'); + $cookieStore.remove('token'); + return $q.reject(response); + } + else { + return $q.reject(response); + } + } + + }; + }) + + .run(function ($rootScope, Auth) { + + $rootScope.Auth = Auth; + + })<% } %>; diff --git a/app/templates/client/index.html b/app/templates/client/index.html index 05ec8c2..028c731 100644 --- a/app/templates/client/index.html +++ b/app/templates/client/index.html @@ -22,7 +22,26 @@
- <%= appname %> + <%= appname %><% if (filters.auth) { %> + + + +logged in: {{ Auth.isLogged() }}<% } %> diff --git a/app/templates/client/services/auth(auth)/auth.js b/app/templates/client/services/auth(auth)/auth.js new file mode 100644 index 0000000..fb7f986 --- /dev/null +++ b/app/templates/client/services/auth(auth)/auth.js @@ -0,0 +1,84 @@ +'use strict'; + +angular.module('<%= appname %>') + .service('Auth', function ($rootScope, $cookieStore, $q, $http) { + + var _user = {}; + + if($cookieStore.get('token')) { + $http.get('/api/users/me') + .then(function (res) { + _user = res.data; + }) + .catch(function (err) { + console.log(err); + }); + } + + /** + * Signup + * + * @param user + * @returns {promise} + */ + this.signup = function (user) { + var deferred = $q.defer(); + $http.post('/api/users', user) + .then(function (res) { + _user = res.data.user; + $cookieStore.put('token', res.data.token); + deferred.resolve(); + }) + .catch(function (err) { + deferred.reject(err.data); + }); + return deferred.promise; + }; + + /** + * Login + * + * @param user + * @returns {promise} + */ + this.login = function (user) { + var deferred = $q.defer(); + $http.post('/auth/local', user) + .then(function (res) { + _user = res.data.user; + $cookieStore.put('token', res.data.token); + deferred.resolve(); + }) + .catch(function (err) { + deferred.reject(err.data); + }); + return deferred.promise; + }; + + /** + * Logout + */ + this.logout = function () { + $cookieStore.remove('token'); + _user = {}; + }; + + /** + * Check if user is logged + * + * @returns {boolean} + */ + this.isLogged = function () { + return _user.hasOwnProperty('email'); + }; + + /** + * Returns the user + * + * @returns {object} + */ + this.getUser = function () { + return _user; + }; + + }); diff --git a/app/templates/client/services/auth(auth)/auth.spec.js b/app/templates/client/services/auth(auth)/auth.spec.js new file mode 100644 index 0000000..a326dbf --- /dev/null +++ b/app/templates/client/services/auth(auth)/auth.spec.js @@ -0,0 +1,34 @@ +'use strict'; + +describe('Service: Auth', function () { + + beforeEach(module('<%= appname %>')); + + var Auth, + $httpBackend, + $cookieStore; + + beforeEach(inject(function (_Auth_, _$httpBackend_, _$cookieStore_) { + Auth = _Auth_; + $httpBackend = _$httpBackend_; + $cookieStore = _$cookieStore_; + $cookieStore.remove('token'); + })); + + afterEach(function() { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('should log user', function () { + expect(Auth.isLogged()).toBe(false); + Auth.login({ email: 'test@test.com', password: 'test' }); + $httpBackend.expectPOST('/auth/local') + .respond({ token: 'abcde', user: { email: 'test@test.com' } }); + $httpBackend.flush(); + expect($cookieStore.get('token')).toBe('abcde'); + expect(Auth.getUser().email).toBe('test@test.com'); + expect(Auth.isLogged()).toBe(true); + }); + +}); diff --git a/app/templates/client/views/login(auth)/login.controller.js b/app/templates/client/views/login(auth)/login.controller.js new file mode 100644 index 0000000..65b46b0 --- /dev/null +++ b/app/templates/client/views/login(auth)/login.controller.js @@ -0,0 +1,32 @@ +'use strict'; + +angular.module('<%= appname %>') + .controller('LoginCtrl', function (Auth, $location) { + + var vm = this; + + angular.extend(vm, { + + name: 'LoginCtrl', + + /** + * User credentials + */ + user: { email: 'test@test.com', password: 'test' }, + + /** + * Login method + */ + login: function () { + Auth.login(vm.user) + .then(function () { + $location.path('/'); + }) + .catch(function (err) { + vm.error = err; + }); + } + + }); + + }); diff --git a/app/templates/client/views/login(auth)/login.html b/app/templates/client/views/login(auth)/login.html new file mode 100644 index 0000000..3826557 --- /dev/null +++ b/app/templates/client/views/login(auth)/login.html @@ -0,0 +1,17 @@ +
{{ vm.error | json }}diff --git a/app/templates/client/views/login(auth)/login.js b/app/templates/client/views/login(auth)/login.js new file mode 100644 index 0000000..1680165 --- /dev/null +++ b/app/templates/client/views/login(auth)/login.js @@ -0,0 +1,11 @@ +'use strict'; + +angular.module('<%= appname %>') + .config(function ($routeProvider) { + $routeProvider + .when('/login', { + templateUrl: 'views/login/login.html', + controller: 'LoginCtrl', + controllerAs: 'vm' + }); + }); diff --git a/app/templates/client/views/login(auth)/login.spec.js b/app/templates/client/views/login(auth)/login.spec.js new file mode 100644 index 0000000..3abcd57 --- /dev/null +++ b/app/templates/client/views/login(auth)/login.spec.js @@ -0,0 +1,30 @@ +'use strict'; + +describe('Controller: LoginCtrl', function () { + + beforeEach(module('<%= appname %>')); + + var LoginCtrl, + $httpBackend, + $location; + + beforeEach(inject(function ($controller, _$httpBackend_, _$location_) { + LoginCtrl = $controller('LoginCtrl', {}); + $httpBackend = _$httpBackend_; + $location = _$location_; + })); + + afterEach(function() { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('should redirect to / after successful login', function () { + LoginCtrl.login({ email: 'test@test.com', password: 'test' }); + $httpBackend.expectPOST('/auth/local') + .respond({ token: 'token' }); + $httpBackend.flush(); + expect($location.path()).toBe('/'); + }); + +}); diff --git a/app/templates/client/views/signup(auth)/signup.controller.js b/app/templates/client/views/signup(auth)/signup.controller.js new file mode 100644 index 0000000..1181299 --- /dev/null +++ b/app/templates/client/views/signup(auth)/signup.controller.js @@ -0,0 +1,32 @@ +'use strict'; + +angular.module('<%= appname %>') + .controller('SignupCtrl', function (Auth, $location) { + + var vm = this; + + angular.extend(vm, { + + name: 'SignupCtrl', + + /** + * User credentials + */ + user: { email: 'test@test.com', password: 'test' }, + + /** + * Signup + */ + signup: function () { + Auth.signup(vm.user) + .then(function () { + $location.path('/'); + }) + .catch(function (err) { + vm.error = err; + }); + } + + }); + + }); diff --git a/app/templates/client/views/signup(auth)/signup.html b/app/templates/client/views/signup(auth)/signup.html new file mode 100644 index 0000000..6fa6a18 --- /dev/null +++ b/app/templates/client/views/signup(auth)/signup.html @@ -0,0 +1,17 @@ +
{{ vm.error | json }}diff --git a/app/templates/client/views/signup(auth)/signup.js b/app/templates/client/views/signup(auth)/signup.js new file mode 100644 index 0000000..c36684b --- /dev/null +++ b/app/templates/client/views/signup(auth)/signup.js @@ -0,0 +1,11 @@ +'use strict'; + +angular.module('<%= appname %>') + .config(function ($routeProvider) { + $routeProvider + .when('/signup', { + templateUrl: 'views/signup/signup.html', + controller: 'SignupCtrl', + controllerAs: 'vm' + }); + }); diff --git a/app/templates/client/views/signup(auth)/signup.spec.js b/app/templates/client/views/signup(auth)/signup.spec.js new file mode 100644 index 0000000..3c35313 --- /dev/null +++ b/app/templates/client/views/signup(auth)/signup.spec.js @@ -0,0 +1,21 @@ +'use strict'; + +describe('Controller: SignupCtrl', function () { + + beforeEach(module('<%= appname %>')); + + var MainCtrl, + scope; + + beforeEach(inject(function ($controller, $rootScope) { + scope = $rootScope.$new(); + MainCtrl = $controller('SignupCtrl', { + $scope: scope + }); + })); + + it('should ...', function () { + expect(1).toBe(1); + }); + +}); diff --git a/app/templates/package.json b/app/templates/package.json index fd0e191..7d0e703 100644 --- a/app/templates/package.json +++ b/app/templates/package.json @@ -37,14 +37,21 @@ }, "dependencies": { "body-parser": "^1.11.0", - "chalk": "^0.5.1", + "chalk": "^0.5.1",<% if (filters.auth) { %> + "composable-middleware": "^0.3.0",<% } %> "compression": "^1.4.0", - "cookie-parser": "^1.3.3", - "express": "^4.11.2", + "cookie-parser": "^1.3.3",<% if (filters.auth) { %> + "connect-mongo": "^0.7.0",<% } %> + "express": "^4.11.2",<% if (filters.auth) { %> + "express-jwt": "^1.0.0", + "express-session": "^1.10.2", + "jsonwebtoken": "^3.2.2",<% } %> "lodash": "^3.0.1", "method-override": "^2.3.1",<% if (filters.backend === 'mongo') { %> "mongoose": "^3.8.22",<% } %> - "morgan": "^1.5.1",<% if (filters.backend === 'restock') { %> + "morgan": "^1.5.1",<% if (filters.auth) { %> + "passport": "^0.2.1", + "passport-local": "^1.0.0",<% } %><% if (filters.backend === 'restock') { %> "request": "^2.51.1",<% } %><% if (filters.sockets) { %> "socket.io": "^1.3.2",<% } %> "should": "^4.6.2", diff --git a/app/templates/server/api/user(auth)/index.js b/app/templates/server/api/user(auth)/index.js new file mode 100644 index 0000000..6af901d --- /dev/null +++ b/app/templates/server/api/user(auth)/index.js @@ -0,0 +1,11 @@ +'use strict'; + +var express = require('express'); +var router = express.Router(); +var controller = require('./user.controller'); +var auth = require('../../auth/auth.service'); + +router.get('/me', auth.isAuthenticated(), controller.getMe); +router.post('/', controller.create); + +module.exports = router; diff --git a/app/templates/server/api/user(auth)/user.controller.js b/app/templates/server/api/user(auth)/user.controller.js new file mode 100644 index 0000000..750e35d --- /dev/null +++ b/app/templates/server/api/user(auth)/user.controller.js @@ -0,0 +1,38 @@ +'use strict'; + +var config = require('../../config/environment'); +var jwt = require('jsonwebtoken'); +var User = require('./user.model'); + +function handleError(res, err) { + return res.status(500).send(err); +} + +/** + * Creates a new user in the DB. + * + * @param req + * @param res + */ +exports.create = function (req, res) { + User.create(req.body, function (err, user) { + if (err) { return handleError(res, err); } + var token = jwt.sign( + {_id: user._id }, + config.secrets.session, + { expiresInMinutes: 60 * 5 } + ); + res.status(201).json({ token: token, user: user }); + }); +}; + +exports.getMe = function (req, res) { + var userId = req.user._id; + User.findOne({ + _id: userId + }, '-salt -passwordHash', function(err, user) { + if (err) { return handleError(res, err); } + if (!user) { return res.json(401); } + res.status(200).json(user); + }); +}; diff --git a/app/templates/server/api/user(auth)/user.model.js b/app/templates/server/api/user(auth)/user.model.js new file mode 100644 index 0000000..3321b63 --- /dev/null +++ b/app/templates/server/api/user(auth)/user.model.js @@ -0,0 +1,85 @@ +'use strict'; + +var crypto = require('crypto'); +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +var UserSchema = new Schema({ + email: String, + passwordHash: String, + salt: String +}); + +/** + * Virtuals + */ + +UserSchema + .virtual('password') + .set(function(password) { + this._password = password; + this.salt = this.makeSalt(); + this.passwordHash = this.encryptPassword(password); + }) + .get(function() { + return this._password; + }); + +/** + * Validations + */ + +UserSchema + .path('email') + .validate(function(value, respond) { + var self = this; + this.constructor.findOne({ email: value }, function(err, user) { + if (err) { throw err; } + if (user) { + if (self.id === user.id) { return respond(true); } + return respond(false); + } + respond(true); + }); + }, 'email already used'); + +/** + * Methods + */ + +UserSchema.methods = { + + /** + * Authenticate + * + * @param {String} password + * @return {Boolean} + */ + authenticate: function(password) { + return this.encryptPassword(password) === this.passwordHash; + }, + + /** + * Make salt + * + * @return {String} + */ + makeSalt: function() { + return crypto.randomBytes(16).toString('base64'); + }, + + /** + * Encrypt password + * + * @param {String} password + * @return {String} + */ + encryptPassword: function(password) { + if (!password || !this.salt) { return ''; } + var salt = new Buffer(this.salt, 'base64'); + return crypto.pbkdf2Sync(password, salt, 10000, 64).toString('base64'); + } + +}; + +module.exports = mongoose.model('User', UserSchema); diff --git a/app/templates/server/auth(auth)/auth.service.js b/app/templates/server/auth(auth)/auth.service.js new file mode 100644 index 0000000..d317f20 --- /dev/null +++ b/app/templates/server/auth(auth)/auth.service.js @@ -0,0 +1,42 @@ +'use strict'; + +var config = require('../config/environment'); +var jwt = require('jsonwebtoken'); +var expressJwt = require('express-jwt'); +var compose = require('composable-middleware'); +var User = require('../api/user/user.model'); +var validateJwt = expressJwt({ secret: config.secrets.session }); + +module.exports = { + + /** + * Attach the user object to the request if authenticated + * Otherwise returns 403 + */ + isAuthenticated: function () { + return compose() + .use(function(req, res, next) { + validateJwt(req, res, next); + }) + .use(function(req, res, next) { + User.findById(req.user._id, function (err, user) { + if (err) { return next(err); } + if (!user) { return res.send(401); } + req.user = user; + next(); + }); + }); + }, + + /** + * Returns a jwt token, signed by the app secret + */ + signToken: function (id) { + return jwt.sign( + { _id: id }, + config.secrets.session, + { expiresInMinutes: 60 * 5 } + ); + } + +}; diff --git a/app/templates/server/auth(auth)/index.js b/app/templates/server/auth(auth)/index.js new file mode 100644 index 0000000..969eb6e --- /dev/null +++ b/app/templates/server/auth(auth)/index.js @@ -0,0 +1,12 @@ +'use strict'; + +var express = require('express'); +var router = express.Router(); +var config = require('../config/environment'); +var User = require('../api/user/user.model'); + +require('./local/passport').setup(User, config); + +router.use('/local', require('./local')); + +module.exports = router; diff --git a/app/templates/server/auth(auth)/local/index.js b/app/templates/server/auth(auth)/local/index.js new file mode 100644 index 0000000..72e8781 --- /dev/null +++ b/app/templates/server/auth(auth)/local/index.js @@ -0,0 +1,19 @@ +'use strict'; + +var express = require('express'); +var passport = require('passport'); +var auth = require('../auth.service'); + +var router = express.Router(); + +router.post('/', function (req, res, next) { + passport.authenticate('local', function (err, user, info) { + var error = err || info; + if (error) { return res.status(401).json(error); } + if (!user) { return res.status(401).json({ msg: 'login failed' }); } + var token = auth.signToken(user._id); + res.json({ token: token, user: user }); + })(req, res, next); +}); + +module.exports = router; diff --git a/app/templates/server/auth(auth)/local/passport.js b/app/templates/server/auth(auth)/local/passport.js new file mode 100644 index 0000000..9b47f18 --- /dev/null +++ b/app/templates/server/auth(auth)/local/passport.js @@ -0,0 +1,24 @@ +'use strict'; + +var passport = require('passport'); +var LocalStrategy = require('passport-local').Strategy; + +exports.setup = function (User) { + passport.use(new LocalStrategy({ + usernameField: 'email', + passwordField: 'password' + }, + function(email, password, done) { + User.findOne({ + email: email + }, function(err, user) { + if (err) { return done(err); } + if (!user) { return done(null, false, { msg: 'email not found' }); } + if (!user.authenticate(password)) { + return done(null, false, { msg: 'incorrect password' }); + } + return done(null, user); + }); + } + )); +}; diff --git a/app/templates/server/config/#express.js b/app/templates/server/config/#express.js deleted file mode 100644 index e693159..0000000 --- a/app/templates/server/config/#express.js +++ /dev/null @@ -1,27 +0,0 @@ -'use strict'; - -var express = require('express'); -var compression = require('compression'); -var morgan = require('morgan'); -var path = require('path'); -var bodyParser = require('body-parser'); - -var config = require('./environment'); - -module.exports = function (app) { - - var env = config.env; - - app.set('view engine', 'html'); - app.use(bodyParser.urlencoded({ extended: false })); - app.use(bodyParser.json()); - app.use(compression()); - app.use(morgan('dev')); - app.use(express.static(path.join(config.root, 'client'))); - app.set('appPath', 'client'); - - if ('development' === env || 'test' === env) { - app.use(require('errorhandler')()); - } - -}; diff --git a/app/templates/server/config/environment/index.js b/app/templates/server/config/environment/index.js index d2947d0..03bcc2d 100644 --- a/app/templates/server/config/environment/index.js +++ b/app/templates/server/config/environment/index.js @@ -15,6 +15,10 @@ var all = { safe: true } } + }<% } %><% if (filters.auth) { %>, + + secrets: { + session: 'zavatta' || process.env.SESSION_SECRET }<% } %> }; diff --git a/app/templates/server/config/express.js b/app/templates/server/config/express.js new file mode 100644 index 0000000..137a26f --- /dev/null +++ b/app/templates/server/config/express.js @@ -0,0 +1,41 @@ +'use strict'; + +var express = require('express'); +var compression = require('compression'); +var morgan = require('morgan'); +var path = require('path'); +var bodyParser = require('body-parser');<% if (filters.auth) { %> + +// auth purpose +var session = require('express-session'); +var passport = require('passport'); +var mongoStore = require('connect-mongo')(session); +var mongoose = require('mongoose');<% } %> + +var config = require('./environment'); + +module.exports = function (app) { + + var env = config.env; + + app.set('view engine', 'html'); + app.use(bodyParser.urlencoded({ extended: false })); + app.use(bodyParser.json()); + app.use(compression()); + app.use(morgan('dev'));<% if (filters.auth) { %> + app.use(passport.initialize());<% } %> + app.use(express.static(path.join(config.root, 'client'))); + app.set('appPath', 'client');<% if (filters.auth) { %> + + app.use(session({ + secret: config.secrets.session, + resave: true, + saveUninitialized: true, + store: new mongoStore({ mongooseConnection: mongoose.connection }) + }));<% } %> + + if ('development' === env || 'test' === env) { + app.use(require('errorhandler')()); + } + +}; diff --git a/app/templates/server/#routes.js b/app/templates/server/routes.js similarity index 73% rename from app/templates/server/#routes.js rename to app/templates/server/routes.js index 6f129f3..d302038 100644 --- a/app/templates/server/#routes.js +++ b/app/templates/server/routes.js @@ -4,7 +4,11 @@ var config = require('./config/environment'); module.exports = function (app) { - // API + // API<% if (filters.auth) { %> + app.use('/api/users', require('./api/user')); + + // Auth + app.use('/auth', require('./auth'));<% } %> app.route('/:url(api|app|bower_components|assets)/*') .get(function (req, res) {