diff --git a/.env.example b/.env.example index 84d2912..2098fed 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,13 @@ NODE_ENV=dev APP="Your App" PORT=3000 +BASE_URI=http://localhost:3000 MONGOURI=mongodb://localhost:27017/yourapp MONGOTESTURI=mongodb://localhost:27017/test-app APP_SECRET=somekey TRANSPORTER_SERVICE=example-gmail TRANSPORTER_EMAIL=example@email.com -TRANSPORTER_PASSWORD=gmail-application_password \ No newline at end of file +TRANSPORTER_PASSWORD=gmail-application_password +DEFAULT_ADMIN_NAME=admin +DEFAULT_ADMIN_EMAIL=example@email.com +DEFAULT_ADMIN_PASSWORD=password \ No newline at end of file diff --git a/README.md b/README.md index 09a4442..f5c782a 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,14 @@ - TRANSPORTER_PASSWORD is password to above email - if gmail you need to generate special app-password, see for further support: https://support.google.com/mail/answer/185833?hl=en) -## Changes from original project +## Changelog - Fixed deprecation warnings with mongoose usage. - Updated dependencies to fix vulnerabilities. - Added email confirmation after registration. +- Distinguish admin and user. ## TODO -- Split users and admins to two collections: - - Rename auth.controller.js to user.controller.js - - create user.route.js and refactor auth.route.js to contain only this demonstration "/secret" routes - Integrate Swagger UI documentation - Write unit tests \ No newline at end of file diff --git a/src/config/index.js b/src/config/index.js index 481b9a8..370aa39 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -2,6 +2,7 @@ require('dotenv').config() // load .env file module.exports = { port: process.env.PORT, + baseURI: process.env.BASE_URI, app: process.env.APP, env: process.env.NODE_ENV, secret: process.env.APP_SECRET, @@ -13,5 +14,10 @@ module.exports = { service: process.env.TRANSPORTER_SERVICE, email: process.env.TRANSPORTER_EMAIL, password: process.env.TRANSPORTER_PASSWORD + }, + admin: { + name: process.env.DEFAULT_ADMIN_NAME, + email: process.env.DEFAULT_ADMIN_EMAIL, + password: process.env.DEFAULT_ADMIN_PASSWORD } } diff --git a/src/controllers/admin.controller.js b/src/controllers/admin.controller.js new file mode 100644 index 0000000..f2fc717 --- /dev/null +++ b/src/controllers/admin.controller.js @@ -0,0 +1,29 @@ +'use strict' + +const Admin = require('../models/admin.model') +const jwt = require('jsonwebtoken') +const config = require('../config') +const httpStatus = require('http-status') +const generateToken = require('../models/utils/findAndGenerateToken') + +exports.register = async (req, res, next) => { + try { + const admin = new Admin(req.body) + const savedAdmin = await admin.save() + res.status(httpStatus.CREATED) + res.send(savedAdmin.transform()) + } catch (error) { + return next(Admin.checkDuplicateEmailError(error)) + } +} + +exports.login = async (req, res, next) => { + try { + const admin = await generateToken(req.body, 'admin') + const payload = { sub: admin.id } + const token = jwt.sign(payload, config.secret) + return res.json({ message: 'OK', token: token }) + } catch (error) { + next(error) + } +} diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js deleted file mode 100644 index 401cd2d..0000000 --- a/src/controllers/auth.controller.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict' - -const User = require('../models/user.model') -const jwt = require('jsonwebtoken') -const config = require('../config') -const httpStatus = require('http-status') -const uuidv1 = require('uuid/v1') - -exports.register = async (req, res, next) => { - try { - const activationKey = uuidv1() - const body = req.body - body.activationKey = activationKey - const user = new User(body) - const savedUser = await user.save() - res.status(httpStatus.CREATED) - res.send(savedUser.transform()) - } catch (error) { - return next(User.checkDuplicateEmailError(error)) - } -} - -exports.login = async (req, res, next) => { - try { - const user = await User.findAndGenerateToken(req.body) - const payload = {sub: user.id} - const token = jwt.sign(payload, config.secret) - return res.json({ message: 'OK', token: token }) - } catch (error) { - next(error) - } -} - -exports.confirm = async (req, res, next) => { - try { - await User.findOneAndUpdate( - { 'activationKey': req.query.key }, - { 'active': true } - ) - return res.json({ message: 'OK' }) - } catch (error) { - next(error) - } -} \ No newline at end of file diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 0000000..a7e0298 --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,115 @@ +'use strict' + +const User = require('../models/user.model') +const jwt = require('jsonwebtoken') +const config = require('../config') +const httpStatus = require('http-status') +const uuidv1 = require('uuid/v1') +const generateToken = require('../models/utils/findAndGenerateToken') +const passport = require('../services/passport') +const bcrypt = require('bcrypt-nodejs') +const transporter = require('../services/transporter') +const APIError = require('../utils/APIError') + +exports.register = async (req, res, next) => { + try { + const activationKey = uuidv1() + const body = req.body + body.activationKey = activationKey + const user = new User(body) + const savedUser = await user.save() + res.status(httpStatus.CREATED) + res.send(savedUser.transform()) + } catch (error) { + return next(User.checkDuplicateEmailError(error)) + } +} + +exports.login = async (req, res, next) => { + try { + const user = await generateToken(req.body, 'user') + const payload = {sub: user.id} + const token = jwt.sign(payload, config.secret) + return res.json({ message: 'OK', token: token }) + } catch (error) { + next(error) + } +} + +exports.update = async (req, res, next) => { + try { + if (!passport.user || !req.body.password) { + res.status(httpStatus.UNAUTHORIZED) + return res.send(new APIError(`Password mismatch`, httpStatus.UNAUTHORIZED)) + } + + const user = await User.findOne({ 'email': passport.user.email }).exec() + if (!user.passwordMatches(req.body.password)) { + res.status(httpStatus.UNAUTHORIZED) + return res.send(new APIError(`Password mismatch`, httpStatus.UNAUTHORIZED)) + } + + if (req.body.newPassword) req.body.password = bcrypt.hashSync(req.body.newPassword) + await User.findOneAndUpdate( + { '_id': passport.user._id }, + { $set: req.body } + ) + return res.json({ message: 'OK' }) + } catch (error) { + next(error) + } +} + +exports.confirm = async (req, res, next) => { + try { + await User.findOneAndUpdate( + { 'activationKey': req.query.key }, + { 'active': true } + ) + return res.json({ message: 'OK' }) + } catch (error) { + next(error) + } +} + +exports.reset = { + async sendMail (req, res, next) { + try { + const resetPasswordKey = uuidv1() + await User.findOneAndUpdate( + { '_id': passport.user._id }, + { 'resetPasswordKey': resetPasswordKey } + ) + const mailOptions = { + from: 'noreply', + to: passport.user.email, + subject: 'Reset password', + html: `

Hello user!

Click link to reset your password.

Hello developer!

Feel free to change this template ;).

` + } + + transporter.sendMail(mailOptions, function (error, info) { + if (error) { + next(error) + } else { + return res.json({ message: 'OK' }) + } + }) + } catch (error) { + next(error) + } + }, + async updatePass (req, res, next) { + try { + if (!req.query.key) return res.status(httpStatus.BAD_REQUEST) + const newPassword = uuidv1() + const newPasswordHash = bcrypt.hashSync(newPassword) + await User.findOneAndUpdate( + { 'resetPasswordKey': req.query.key }, + { $set: { 'password': newPasswordHash, 'resetPasswordKey': '' } } + ) + return res.json({ message: 'OK', newPassword: newPassword }) + } catch (error) { + next(error) + } + } +} diff --git a/src/middlewares/authorization.js b/src/middlewares/authorization.js index ecad145..68cc8f5 100644 --- a/src/middlewares/authorization.js +++ b/src/middlewares/authorization.js @@ -1,18 +1,16 @@ 'use strict' -const User = require('../models/user.model') const passport = require('passport') const APIError = require('../utils/APIError') const httpStatus = require('http-status') const bluebird = require('bluebird') // handleJWT with roles -const handleJWT = (req, res, next, roles) => async (err, user, info) => { +const handleJWT = (req, res, next, role) => async (err, user, info) => { const error = err || info const logIn = bluebird.promisify(req.logIn) const apiError = new APIError( - error ? error.message : 'Unauthorized', - httpStatus.UNAUTHORIZED + error ? error.message : 'Unauthorized', httpStatus.UNAUTHORIZED ) // log user in @@ -24,7 +22,7 @@ const handleJWT = (req, res, next, roles) => async (err, user, info) => { } // see if user is authorized to do the action - if (!roles.includes(user.role)) { + if (role && role.includes('admin') && !user.admin) { return next(new APIError('Forbidden', httpStatus.FORBIDDEN)) } @@ -34,11 +32,11 @@ const handleJWT = (req, res, next, roles) => async (err, user, info) => { } // exports the middleware -const authorize = (roles = User.roles) => (req, res, next) => +const authorize = (role) => (req, res, next) => passport.authenticate( 'jwt', { session: false }, - handleJWT(req, res, next, roles) + handleJWT(req, res, next, role) )(req, res, next) module.exports = authorize diff --git a/src/models/admin.model.js b/src/models/admin.model.js new file mode 100644 index 0000000..c6d5a69 --- /dev/null +++ b/src/models/admin.model.js @@ -0,0 +1,50 @@ +'use strict' +const mongoose = require('mongoose') +const bcrypt = require('bcrypt-nodejs') +const Schema = mongoose.Schema +const hashPass = require('./utils/hashPass') +const checkDuplicateEmailError = require('./utils/checkDuplicateEmailError') + +const adminSchema = new Schema({ + email: { + type: String, + required: true, + unique: true, + lowercase: true + }, + password: { + type: String, + required: true, + minlength: 4, + maxlength: 128 + }, + name: { + type: String, + maxlength: 50 + }, + admin: { + type: Boolean, + default: true + } +}, { + timestamps: true +}) + +hashPass(adminSchema, 'save') + +adminSchema.method({ + transform () { + const transformed = {} + const fields = ['id', 'name', 'email', 'createdAt'] + fields.forEach((field) => { transformed[field] = this[field] }) + return transformed + }, + + passwordMatches (password) { + return bcrypt.compareSync(password, this.password) + } +}) + +adminSchema.statics = { checkDuplicateEmailError } + +module.exports = mongoose.model('Admin', adminSchema) diff --git a/src/models/user.model.js b/src/models/user.model.js index 969b711..a4e8779 100644 --- a/src/models/user.model.js +++ b/src/models/user.model.js @@ -1,14 +1,12 @@ 'use strict' + const mongoose = require('mongoose') const bcrypt = require('bcrypt-nodejs') -const httpStatus = require('http-status') -const APIError = require('../utils/APIError') const transporter = require('../services/transporter') +const config = require('../config') const Schema = mongoose.Schema - -const roles = [ - 'user', 'admin' -] +const hashPass = require('./utils/hashPass') +const checkDuplicateEmailError = require('./utils/checkDuplicateEmailError') const userSchema = new Schema({ email: { @@ -35,42 +33,22 @@ const userSchema = new Schema({ type: Boolean, default: false }, - role: { - type: String, - default: 'user', - enum: roles + resetPasswordKey: { + type: String } }, { timestamps: true }) -userSchema.pre('save', async function save (next) { - try { - if (!this.isModified('password')) { - return next() - } - - this.password = bcrypt.hashSync(this.password) - - return next() - } catch (error) { - return next(error) - } -}) +hashPass(userSchema, 'save') userSchema.post('save', async function saved (doc, next) { try { - console.log('after save is called') - if (!this.isModified('activationKey')) { - console.log('Not modified.. but what does it mean?') - //return next() - } - const mailOptions = { from: 'noreply', to: this.email, subject: 'Confirm creating account', - html: `

Hello new user!

Click link to activate your new account.

Hello developer!

Feel free to change this template ;).

` + html: `

Hello new user!

Click link to activate your new account.

Hello developer!

Feel free to change this template ;).

` } transporter.sendMail(mailOptions, function (error, info) { @@ -88,14 +66,10 @@ userSchema.post('save', async function saved (doc, next) { }) userSchema.method({ - transform () { + transform: function () { const transformed = {} - const fields = ['id', 'name', 'email', 'createdAt', 'activationKey', 'role'] - - fields.forEach((field) => { - transformed[field] = this[field] - }) - + const fields = ['id', 'name', 'email', 'createdAt'] + fields.forEach(field => { transformed[field] = this[field] }) return transformed }, @@ -104,37 +78,6 @@ userSchema.method({ } }) -userSchema.statics = { - roles, - - checkDuplicateEmailError (err) { - if (err.code === 11000) { - var error = new Error('Email already taken') - error.errors = [{ - field: 'email', - location: 'body', - messages: ['Email already taken'] - }] - error.status = httpStatus.CONFLICT - return error - } - - return err - }, - - async findAndGenerateToken (payload) { - const { email, password } = payload - if (!email) throw new APIError('Email must be provided for login') - - const user = await this.findOne({ email }).exec() - if (!user) throw new APIError(`No user associated with ${email}`, httpStatus.NOT_FOUND) - - const passwordOK = await user.passwordMatches(password) - - if (!passwordOK) throw new APIError(`Password mismatch`, httpStatus.UNAUTHORIZED) - - return user - } -} +userSchema.statics = { checkDuplicateEmailError } module.exports = mongoose.model('User', userSchema) diff --git a/src/models/utils/checkDuplicateEmailError.js b/src/models/utils/checkDuplicateEmailError.js new file mode 100644 index 0000000..cedaa86 --- /dev/null +++ b/src/models/utils/checkDuplicateEmailError.js @@ -0,0 +1,20 @@ +'use strict' + +const httpStatus = require('http-status') + +function checkDuplicateEmailError (err) { + if (err.code === 11000) { + var error = new Error('Email already taken') + error.errors = [{ + field: 'email', + location: 'body', + messages: ['Email already taken'] + }] + error.status = httpStatus.CONFLICT + return error + } + + return err +} + +module.exports = checkDuplicateEmailError diff --git a/src/models/utils/findAndGenerateToken.js b/src/models/utils/findAndGenerateToken.js new file mode 100644 index 0000000..2182822 --- /dev/null +++ b/src/models/utils/findAndGenerateToken.js @@ -0,0 +1,32 @@ +'use strict' + +const httpStatus = require('http-status') +const APIError = require('../../utils/APIError') +const User = require('../user.model') +const Admin = require('../admin.model') + +async function findAndGenerateToken (payload, from) { + const { email, password } = payload + if (!email) throw new APIError('Email must be provided for login') + let user + + if (from === 'admin') { + user = await Admin.findOne({ email }).exec() + } + if (from === 'user') { + user = await User.findOne({ email }).exec() + } + + if (!user) throw new APIError(`No user associated with ${email}`, httpStatus.NOT_FOUND) + + const passwordOK = await user.passwordMatches(password) + + if (!passwordOK) throw new APIError(`Password mismatch`, httpStatus.UNAUTHORIZED) + if (from === 'user' && !user.active) { + throw new APIError(`User not activated`, httpStatus.UNAUTHORIZED) + } + + return user +} + +module.exports = findAndGenerateToken diff --git a/src/models/utils/hashPass.js b/src/models/utils/hashPass.js new file mode 100644 index 0000000..2fa9f2a --- /dev/null +++ b/src/models/utils/hashPass.js @@ -0,0 +1,21 @@ +'use strict' + +const bcrypt = require('bcrypt-nodejs') + +const hashPass = (schema, action) => { + schema.pre(action, async function save (next) { + try { + if (!this.isModified('password')) { + return next() + } + + this.password = bcrypt.hashSync(this.password) + + return next() + } catch (error) { + return next(error) + } + }) +} + +module.exports = hashPass diff --git a/src/routes/api/admin.route.js b/src/routes/api/admin.route.js new file mode 100644 index 0000000..3d8d6d3 --- /dev/null +++ b/src/routes/api/admin.route.js @@ -0,0 +1,11 @@ +'use strict' + +const express = require('express') +const router = express.Router() +const adminController = require('../../controllers/admin.controller') +const auth = require('../../middlewares/authorization') + +router.post('/login', adminController.login) +router.post('/register', auth(['admin']), adminController.register) + +module.exports = router diff --git a/src/routes/api/auth.route.js b/src/routes/api/auth.route.js index e43a74e..70fcbf8 100644 --- a/src/routes/api/auth.route.js +++ b/src/routes/api/auth.route.js @@ -2,27 +2,14 @@ const express = require('express') const router = express.Router() -const authController = require('../../controllers/auth.controller') -const validator = require('express-validation') -const { create } = require('../../validations/user.validation') const auth = require('../../middlewares/authorization') -router.post('/register', validator(create), authController.register) // validate and register -router.post('/login', authController.login) // login -router.get('/confirm', authController.confirm) - -// Authentication example +// Authentication example routes router.get('/secret1', auth(), (req, res) => { - // example route for auth res.json({ message: 'Anyone can access(only authorized)' }) }) router.get('/secret2', auth(['admin']), (req, res) => { - // example route for auth res.json({ message: 'Only admin can access' }) }) -router.get('/secret3', auth(['user']), (req, res) => { - // example route for auth - res.json({ message: 'Only user can access' }) -}) module.exports = router diff --git a/src/routes/api/index.js b/src/routes/api/index.js deleted file mode 100644 index 83c6816..0000000 --- a/src/routes/api/index.js +++ /dev/null @@ -1,10 +0,0 @@ -'use strict' -const express = require('express') -const router = express.Router() -const authRouter = require('./auth.route') - -router.get('/status', (req, res) => { res.send({status: 'OK'}) }) // api status - -router.use('/auth', authRouter) // mount auth paths - -module.exports = router diff --git a/src/routes/api/user.route.js b/src/routes/api/user.route.js new file mode 100644 index 0000000..e94e8aa --- /dev/null +++ b/src/routes/api/user.route.js @@ -0,0 +1,20 @@ +'use strict' + +const express = require('express') +const router = express.Router() +const userController = require('../../controllers/user.controller') +const validator = require('express-validation') +const { create } = require('../../validations/user.validation') +const { update } = require('../../validations/user.validation') +const auth = require('../../middlewares/authorization') + +router.post('/register', validator(create), userController.register) +router.post('/login', userController.login) +router.post('/resetStart', auth(), userController.reset.sendMail) + +router.get('/confirm', userController.confirm) +router.get('/resetConfirm', userController.reset.updatePass) + +router.put('/update', validator(update), auth(), userController.update) + +module.exports = router diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..b3dd6cf --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,16 @@ +'use strict' + +const express = require('express') +const router = express.Router() +const adminRouter = require('./api/admin.route') +const userRouter = require('./api/user.route') +const authRouter = require('./api/auth.route') + +// api status +router.get('/status', (req, res) => { res.send({status: 'OK'}) }) + +router.use('/admin', adminRouter) +router.use('/user', userRouter) +router.use('/auth', authRouter) + +module.exports = router diff --git a/src/services/express.js b/src/services/express.js index b6020a4..c764cab 100644 --- a/src/services/express.js +++ b/src/services/express.js @@ -7,7 +7,7 @@ const cors = require('cors') const helmet = require('helmet') const bodyParser = require('body-parser') const errorHandler = require('../middlewares/error-handler') -const apiRouter = require('../routes/api') +const apiRouter = require('../routes/index') const passport = require('passport') const passportJwt = require('../services/passport') diff --git a/src/services/mongoose.js b/src/services/mongoose.js index 83fbb95..5c35128 100644 --- a/src/services/mongoose.js +++ b/src/services/mongoose.js @@ -2,6 +2,7 @@ const config = require('../config') const mongoose = require('mongoose') +const seed = require('../utils/seed') mongoose.Promise = require('bluebird') mongoose.connection.on('connected', () => { @@ -25,7 +26,10 @@ exports.connect = () => { useNewUrlParser: true }) + seed() + mongoose.set('useCreateIndex', true) + mongoose.set('useFindAndModify', false) return mongoose.connection } diff --git a/src/services/passport.js b/src/services/passport.js index 01aa7af..f55f9da 100644 --- a/src/services/passport.js +++ b/src/services/passport.js @@ -2,6 +2,7 @@ const config = require('../config') const User = require('../models/user.model') +const Admin = require('../models/admin.model') const passportJWT = require('passport-jwt') const ExtractJwt = passportJWT.ExtractJwt @@ -20,9 +21,20 @@ const jwtStrategy = new JwtStrategy(jwtOptions, (jwtPayload, done) => { } if (user) { + exports.user = user return done(null, user) } else { - return done(null, false) + Admin.findById(jwtPayload.sub, (err, admin) => { + if (err) { + return done(err, null) + } + if (admin) { + exports.user = admin + return done(null, admin) + } else { + return done(null, false) + } + }) } }) }) diff --git a/src/utils/seed.js b/src/utils/seed.js new file mode 100644 index 0000000..8226ea7 --- /dev/null +++ b/src/utils/seed.js @@ -0,0 +1,25 @@ +'use strict' + +const Admin = require('../models/admin.model') +const config = require('../config') + +async function seed () { + try { + const count = await Admin.countDocuments({}) + + if (count === 0) { + const user = { + name: config.admin.name, + email: config.admin.email, + password: config.admin.password + } + + const admin = new Admin(user) + await admin.save() + } + } catch (err) { + console.error(err) + } +} + +module.exports = seed diff --git a/src/validations/user.validation.js b/src/validations/user.validation.js index 046de48..9e4b02f 100644 --- a/src/validations/user.validation.js +++ b/src/validations/user.validation.js @@ -10,5 +10,17 @@ module.exports = { password: Joi.string().min(6).max(128).required(), name: Joi.string().max(128).required() } + }, + update: { + body: { + _id: Joi.any().forbidden(), + email: Joi.any().forbidden(), + password: Joi.string().min(6).max(128), + newPassword: Joi.string().min(6).max(128), + name: Joi.string().max(128), + activationKey: Joi.any().forbidden(), + active: Joi.any().forbidden(), + resetPasswordKey: Joi.any().forbidden() + } } }