diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..ca51474 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,21 @@ +{ + "rules": { + "no-console": "off", + "indent": [ "error", 2 ], + "quotes": [ "error", "single" ], + "semi": ["error", "always"], + "linebreak-style": [ "error", "unix" ] + }, + "env": { + "es6": true, + "node": true, + "mocha": true, + "jasmine": true + }, + "ecmaFeatures": { + "modules": true, + "experimentalObjectRestSpread": true, + "impliedStrict": true + }, + "extends": "eslint:recommended" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94932bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,119 @@ +# Created by https://www.gitignore.io/api/node,vim,osx,macos,linux + +node_modules + +### Node ### +.env +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + + + +### Vim ### +# swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-v][a-z] +[._]sw[a-p] +# session +Session.vim +# temporary +.netrwhist +*~ +# auto-generated tag files +tags + + +### OSX ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon +# Thumbnails +._* +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +### macOS ### +# Icon must end with two \r +# Thumbnails +# Files that might appear in the root of a volume +# Directories potentially created on remote AFP share + + +### Linux ### + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# End of https://www.gitignore.io/api/node,vim,osx,macos,linux +l diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..6059bf9 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,27 @@ +'use strict'; + +const gulp = require('gulp'); +const eslint = require('gulp-eslint'); +const mocha = require('gulp-mocha'); + +gulp.task('test', function() { + gulp.src('./test/*-test.js', { + read: false + }) + .pipe(mocha({ + reporter: 'spec' + })); +}); + +gulp.task('lint', function() { + return gulp.src(['**/*.js', '!node_modules']) + .pipe(eslint()) + .pipe(eslint.format()) + .pipe(eslint.failAfterError()); +}); + +gulp.task('dev', function() { + gulp.watch(['**/*.js', '!node_modules/**'], ['lint', 'test']); +}); + +gulp.task('default', ['dev']); diff --git a/lib/basic-auth-middleware.js b/lib/basic-auth-middleware.js new file mode 100644 index 0000000..d4d617a --- /dev/null +++ b/lib/basic-auth-middleware.js @@ -0,0 +1,36 @@ +'use strict'; + +const createError = require('http-errors'); +const debug = require('debug')('cfgram:basic-auth-middleware'); + +module.exports = function(req, res, next) { + debug('auth'); + + var authHeader = req.headers.authorization; + if (!authHeader) { + return next(createError(401, 'authorization header required')); + }; + + var base64str = authHeader.split('Basic ')[1]; + if (!base64str) { + return next(createError(401, 'username and password required')); + }; + + var utf8str = new Buffer(base64str, 'base64').toString(); + var authArr = utf8str.split(':'); + + req.auth = { + username: authArr[0], + password: authArr[1] + }; + + if (!req.auth.username) { + return next(createError(401, 'username required')); + }; + + if (!req.auth.password) { + return next(createError(401, 'password required')); + }; + + next(); +}; diff --git a/lib/bearer-auth-middleware.js b/lib/bearer-auth-middleware.js new file mode 100644 index 0000000..cf81b3a --- /dev/null +++ b/lib/bearer-auth-middleware.js @@ -0,0 +1,7 @@ +'use strict'; + +const jwt = require('jsonwebtoken'); +const createError = require('http-errors'); +const debug = require('debug')('cfgram:bearer-auth-middleware'); + +const User = require('../model/user.js'); diff --git a/lib/error-middleware.js b/lib/error-middleware.js new file mode 100644 index 0000000..9b3756d --- /dev/null +++ b/lib/error-middleware.js @@ -0,0 +1,28 @@ +'use strict'; + +const createError = require('http-errors'); +const debug = require('debug')('cfgram:error-middleware'); + +module.exports = function(err, req, res, next) { + debug('error middleware'); + + console.error('msg:', err.message); + console.error('name:', err.name); + + if (err.status) { + res.status(err.status).send(err.name); + next(); + return; + }; + + if (err.name === 'ValidationError') { + err = createError(400, err.message); + res.status(err.status).send(err.name); + next(); + return; + }; + + err = createError(500, err.message); + res.status(err.status).send(err.name); + next(); +}; diff --git a/model/gallery.js b/model/gallery.js new file mode 100644 index 0000000..e69de29 diff --git a/model/user.js b/model/user.js new file mode 100644 index 0000000..a077a52 --- /dev/null +++ b/model/user.js @@ -0,0 +1,77 @@ + +'use strict'; + +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const mongoose = require('mongoose'); +const createError = require('http-errors'); +const Promise = require('bluebird'); +const debug = require('debug')('cfgram:user'); + +const Schema = mongoose.Schema; + +const userSchema = Schema({ + username: { type: String, required: true, unique: true }, + email: { type: String, required: true, unique: true }, + password: { type: String, required: true }, + findHash: { type: String, unique: true } +}); + +userSchema.methods.generatePasswordHash = function(password) { + debug('generatePasswordHash'); + + return new Promise((resolve, reject) => { + bcrypt.hash(password, 10, (err, hash) => { + if (err) return reject(err); + this.password = hash; + resolve(this); + }); + }); +}; + +userSchema.methods.comparePasswordHash = function(password) { + debug('comparePasswordHash'); + + return new Promise((resolve, reject) => { + bcrypt.compare(password, this.password, (err, valid) => { + if (err) return reject(err); + if (!valid) return reject(createError(401, 'wrong password')); + resolve(this); + }); + }); +}; + + +userSchema.methods.generateFindHash = function() { + debug('generateFindHash'); + + return new Promise((resolve, reject) => { + let tries = 0; + + _generateFindHash.call(this); + + function _generateFindHash() { + this.findHash = crypto.randomBytes(32).toString('hex'); + this.save() + .then(() => resolve(this.findHash)) + .catch( err => { + if (tries > 3) return reject(err); + tries++; + _generateFindHash.call(this); + }); + }; + }); +}; + +userSchema.methods.generateToken = function() { + debug('generateToken'); + + return new Promise((resolve, reject) => { + this.generateFindHash() + .then(findHash => resolve(jwt.sign({ token: findHash }, process.env.APP_SECRET))) + .catch( err => reject(err)); + }); +}; + +module.exports = mongoose.model('user', userSchema); diff --git a/package.json b/package.json new file mode 100644 index 0000000..f566058 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "16-basic_auth", + "version": "1.0.0", + "description": "![cf](https://i.imgur.com/7v5ASc8.png) Lab 16 - Basic Auth ======", + "main": "index.js", + "scripts": { + "test": "DEBUG= 'cfgram*' mocha", + "start": "DEBUG= 'cfgram*' node server.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sueanyv/16-basic_auth.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/sueanyv/16-basic_auth/issues" + }, + "homepage": "https://github.com/sueanyv/16-basic_auth#readme", + "dependencies": { + "aws-sdk": "^2.24.0", + "bcrypt": "^1.0.2", + "bluebird": "^3.5.0", + "body-parser": "^1.17.1", + "cors": "^2.8.1", + "debug": "^2.6.1", + "del": "^2.2.2", + "dotenv": "^4.0.0", + "express": "^4.15.2", + "http-errors": "^1.6.1", + "jsonwebtoken": "^7.3.0", + "mongoose": "^4.8.6", + "morgan": "^1.8.1", + "multer": "^1.3.0" + }, + "devDependencies": { + "chai": "^3.5.0", + "mocha": "^3.2.0", + "superagent": "^3.5.0" + } +} diff --git a/route/auth-router.js b/route/auth-router.js new file mode 100644 index 0000000..c2c3133 --- /dev/null +++ b/route/auth-router.js @@ -0,0 +1,35 @@ +'use strict'; + +const jsonParser = require('body-parser').json(); +const debug = require('debug')('cfgram:auth-router'); +const Router = require('express').Router; +const basicAuth = require('../lib/basic-auth-middleware.js'); + +const User = require('../model/user.js'); + +const authRouter = module.exports = Router(); + +authRouter.post('/api/signup', jsonParser, function(req, res, next) { + debug('POST /api/signup'); + + let password = req.body.password; + delete req.body.password; + + let user = new User(req.body); + + user.generatePasswordHash(password) + .then( user => user.save()) + .then( user => user.generateToken()) + .then( token => res.send(token)) + .catch(next); +}); + +authRouter.get('/api/signin', basicAuth, function(req, res, next) { + debug('GET /api/signin'); + + User.findOne({ username: req.auth.username }) + .then( user => user.comparePasswordHash(req.auth.password)) + .then( user => user.generateToken()) + .then( token => res.send(token)) + .catch(next); +}); diff --git a/route/gallery-router.js b/route/gallery-router.js new file mode 100644 index 0000000..cfe29cc --- /dev/null +++ b/route/gallery-router.js @@ -0,0 +1,33 @@ +'use strict'; + +const Router = require('express').Router; +const jsonParser = require('body-parser').json(); +const createError = require('http-errors'); +const debug = require('debug')('cfgram:gallery-router'); + +const Gallery = require('../model/gallery.js'); +const bearerAuth = require('../lib/bearer-auth-middleware.js'); + +const galleryRouter = module.exports = Router(); + +galleryRouter.post('/api/gallery', bearerAuth, jsonParser, function(req, res, next) { + debug('POST: /api/gallery'); + + req.body.userID = req.user._id; + new Gallery(req.body).save() + .then( gallery => res.json(gallery)) + .catch(next); +}); + +galleryRouter.get('/api/gallery/:id', bearerAuth, function(req, res, next) { + debug('GET: /api/gallery/:id'); + + Gallery.findById(req.params.id) + .then( gallery => { + if (gallery.userID.toString() !== req.user._id.toString()) { + return next(createError(401, 'invalid user')); + } + res.json(gallery); + }) + .catch(next); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..e69de29 diff --git a/test/auth-route-test.js b/test/auth-route-test.js new file mode 100644 index 0000000..7a31206 --- /dev/null +++ b/test/auth-route-test.js @@ -0,0 +1,77 @@ +'use strict'; + +const expect = require('chai').expect; +const request = require('superagent'); +const mongoose = require('mongoose'); +const Promise = require('bluebird'); +const User = require('../model/user.js'); + +mongoose.Promise = Promise; + +require('../server.js'); + +const url = `http://localhost:${process.env.PORT}`; + +const exampleUser = { + username: 'exampleuser', + password: '1234', + email: 'exampleuser@test.com' +}; + +describe('Auth Routes', function() { + describe('POST: /api/signup', function() { + describe('with a valid body', function() { + after( done => { + User.remove({}) + .then( () => done()) + .catch(done); + }); + + it('should return a token', done => { + request.post(`${url}/api/signup`) + .send(exampleUser) + .end((err, res) => { + if (err) return done(err); + console.log('\ntoken:', res.text, '\n'); + expect(res.status).to.equal(200); + expect(res.text).to.be.a('string'); + done(); + }); + }); + }); + }); + + describe('GET: /api/signin', function() { + describe('with a valid body', function() { + before( done => { + let user = new User(exampleUser); + user.generatePasswordHash(exampleUser.password) + .then( user => user.save()) + .then( user => { + this.tempUser = user; + done(); + }) + .catch(done); + }); + + after( done => { + User.remove({}) + .then( () => done()) + .catch(done); + }); + + it('should return a token', done => { + request.get(`${url}/api/signin`) + .auth('exampleuser', '1234') + .end((err, res) => { + if (err) return done(err); + console.log('\nuser:', this.tempUser); + console.log('\ntoken:', res.text); + expect(res.status).to.equal(200); + done(); + }); + }); + }); + }); +}); + diff --git a/test/data/butterfly.png b/test/data/butterfly.png new file mode 100644 index 0000000..7323969 Binary files /dev/null and b/test/data/butterfly.png differ diff --git a/test/gallery-route-test.js b/test/gallery-route-test.js new file mode 100644 index 0000000..7f03523 --- /dev/null +++ b/test/gallery-route-test.js @@ -0,0 +1,116 @@ +'use strict'; + +const expect = require('chai').expect; +const request = require('superagent'); +const mongoose = require('mongoose'); +const Promise = require('bluebird'); + +const User = require('../model/user.js'); +const Gallery = require('../model/gallery.js'); + +const url = `http://localhost:${process.env.PORT}`; + +const exampleUser = { + username: 'exampleuser', + password: '1234', + email: 'exampleuser@test.com' +}; + +const exampleGallery = { + name: 'test gallery', + desc: 'test gallery description' +}; + +mongoose.Promise = Promise; + +describe('Gallery Routes', function() { + afterEach( done => { + Promise.all([ + User.remove({}), + Gallery.remove({}) + ]) + .then( () => done()) + .catch(done) + }); + + describe('POST: /api/gallery', () => { + before( done => { + new User(exampleUser) + .generatePasswordHash(exampleUser.password) + .then( user => user.save()) + .then( user => { + this.tempUser = user; + return user.generateToken(); + }) + .then( token => { + this.tempToken = token; + done(); + }) + .catch(done); + }); + + it('should return a gallery', done => { + request.post(`${url}/api/gallery`) + .send(exampleGallery) + .set({ + Authorization: `Bearer ${this.tempToken}` + }) + .end((err, res) => { + if (err) return done(err); + let date = new Date(res.body.created).toString(); + expect(res.body.name).to.equal(exampleGallery.name); + expect(res.body.desc).to.equal(exampleGallery.desc); + expect(res.body.userID).to.equal(this.tempUser._id.toString()); + expect(date).to.not.equal('Invalid Date'); + done(); + }); + }); + }); + + describe('GET: /api/gallery/:id', () => { + before( done => { + new User(exampleUser) + .generatePasswordHash(exampleUser.password) + .then( user => user.save()) + .then( user => { + this.tempUser = user; + return user.generateToken(); + }) + .then( token => { + this.tempToken = token; + done(); + }) + .catch(done); + }); + + before( done => { + exampleGallery.userID = this.tempUser._id.toString(); + new Gallery(exampleGallery).save() + .then( gallery => { + this.tempGallery = gallery; + done(); + }) + .catch(done); + }); + + after( () => { + delete exampleGallery.userID; + }); + + it('should return a gallery', done => { + request.get(`${url}/api/gallery/${this.tempGallery._id}`) + .set({ + Authorization: `Bearer ${this.tempToken}` + }) + .end((err, res) => { + if (err) return done(err); + let date = new Date(res.body.created).toString(); + expect(res.body.name).to.equal(exampleGallery.name); + expect(res.body.desc).to.equal(exampleGallery.desc); + expect(res.body.userID).to.equal(this.tempUser._id.toString()) + expect(date).to.not.equal('Invalid Date'); + done(); + }); + }); + }); +}); diff --git a/test/lib/server-toggle.js b/test/lib/server-toggle.js new file mode 100644 index 0000000..3551ab2 --- /dev/null +++ b/test/lib/server-toggle.js @@ -0,0 +1,30 @@ +'use strict'; + +const debug = require('debug')('cfgram:server-toggle'); + +module.exports = exports = {}; + +exports.serverOn = function(server, done) { + if (!server.isRunning) { + server.listen(process.env.PORT, () => { + server.isRunning = true; + debug('server up!'); + done(); + }); + return; + }; + done(); +}; + +exports.serverOff = function(server, done) { + if(server.isRunning) { + server.close( err => { + if (err) return done(err); + server.isRunning = false; + debug('server down!'); + done(); + }); + return; + }; + done(); +}; diff --git a/test/pic-router-test.js b/test/pic-router-test.js new file mode 100644 index 0000000..5635e51 --- /dev/null +++ b/test/pic-router-test.js @@ -0,0 +1,102 @@ +'use strict'; + +const expect = require('chai').expect; +const request = require('superagent'); +const debug = require('debug')('cfgram:pic-router-test'); + +const Pic = require('../model/pic.js'); +const User = require('../model/user.js'); +const Gallery = require('../model/gallery.js'); + +const serverToggle = require('./lib/server-toggle.js'); +const server = require('../server.js'); + +const url = `http://localhost:${process.env.PORT}`; + +const exampleUser = { + username: 'exampleuser', + password: '1234', + email: 'exampleuser@test.com' +}; + +const exampleGallery = { + name: 'test gallery', + desc: 'test gallery description' +}; + +const examplePic = { + name: 'example pic', + desc: 'example pic description', + image: `${__dirname}/data/butterfly.png` +}; + +describe('Pic Routes', function() { + before( done => { + serverToggle.serverOn(server, done); + }); + + after( done => { + serverToggle.serverOff(server, done); + }); + + afterEach( done => { + Promise.all([ + Pic.remove({}), + User.remove({}), + Gallery.remove({}) + ]) + .then( () => done()) + .catch(done); + }); + + describe('POST: /api/gallery/:id/pic', function() { + describe('with a valid token and valid data', function() { + before( done => { + new User(exampleUser) + .generatePasswordHash(exampleUser.password) + .then( user => user.save()) + .then( user => { + this.tempUser = user; + return user.generateToken(); + }) + .then( token => { + this.tempToken = token; + done(); + }) + .catch(done); + }); + + before( done => { + exampleGallery.userID = this.tempUser._id.toString(); + new Gallery(exampleGallery).save() + .then( gallery => { + this.tempGallery = gallery; + done(); + }) + .catch(done); + }); + + after( done => { + delete exampleGallery.userID; + done(); + }); + + it('should return a pic', done => { + request.post(`${url}/api/gallery/${this.tempGallery._id}/pic`) + .set({ + Authorization: `Bearer ${this.tempToken}` + }) + .field('name', examplePic.name) + .field('desc', examplePic.desc) + .attach('image', examplePic.image) + .end((err, res) => { + if (err) return done(err); + expect(res.body.name).to.equal(examplePic.name); + expect(res.body.desc).to.equal(examplePic.desc); + expect(res.body.galleryID).to.equal(this.tempGallery._id.toString()); + done(); + }); + }); + }); + }); +});