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()
+ }
}
}