Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ MONGOTESTURI=mongodb://localhost:27017/test-app
APP_SECRET=somekey
TRANSPORTER_SERVICE=example-gmail
TRANSPORTER_EMAIL=example@email.com
TRANSPORTER_PASSWORD=gmail-application_password
TRANSPORTER_PASSWORD=gmail-application_password
DEFAULT_ADMIN_NAME=admin
DEFAULT_ADMIN_EMAIL=example@email.com
DEFAULT_ADMIN_PASSWORD=password
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a new line at the end of file

6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,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
5 changes: 5 additions & 0 deletions src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,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
}
}
29 changes: 29 additions & 0 deletions src/controllers/admin.controller.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const jwt = require('jsonwebtoken')
const config = require('../config')
const httpStatus = require('http-status')
const uuidv1 = require('uuid/v1')
const generateToken = require('../models/utils/findAndGenerateToken')

exports.register = async (req, res, next) => {
try {
Expand All @@ -22,7 +23,7 @@ exports.register = async (req, res, next) => {

exports.login = async (req, res, next) => {
try {
const user = await User.findAndGenerateToken(req.body)
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 })
Expand All @@ -41,4 +42,4 @@ exports.confirm = async (req, res, next) => {
} catch (error) {
next(error)
}
}
}
12 changes: 5 additions & 7 deletions src/middlewares/authorization.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method was used to add new roles to user model. Not just admin. There maybe an editor, moderator etc as many as possible.

In route definition we can add which roles can see the resouece. Like say you want a protected route only for admins and editors.

we can simply put

route.post('/page-edit', authorize(['admin', 'editor']))

return next(new APIError('Forbidden', httpStatus.FORBIDDEN))
}

Expand All @@ -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
50 changes: 50 additions & 0 deletions src/models/admin.model.js
Original file line number Diff line number Diff line change
@@ -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)

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)
75 changes: 9 additions & 66 deletions src/models/user.model.js
Original file line number Diff line number Diff line change
@@ -1,15 +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: {
Expand All @@ -35,37 +32,20 @@ const userSchema = new Schema({
active: {
type: Boolean,
default: false
},
role: {
type: String,
default: 'user',
enum: roles
}
}, {
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)

userSchema.post('save', async function saved (doc, next) {
try {
const mailOptions = {
from: 'noreply',
to: this.email,
subject: 'Confirm creating account',
html: `<div><h1>Hello new user!</h1><p>Click <a href="${config.hostname}/api/auth/confirm?key=${this.activationKey}">link</a> to activate your new account.</p></div><div><h1>Hello developer!</h1><p>Feel free to change this template ;).</p></div>`
html: `<div><h1>Hello new user!</h1><p>Click <a href="${config.hostname}/api/user/confirm?key=${this.activationKey}">link</a> to activate your new account.</p></div><div><h1>Hello developer!</h1><p>Feel free to change this template ;).</p></div>`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config.hostname is not available, this will not work

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a config param. Check in config file

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look here

But it seems I've forgotten to add it to .env.example somehow

Copy link
Contributor Author

@d0peCode d0peCode Jul 3, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I didn't know but wanted to keep this change so I renamed it to BASE_URI in next PR. Sorry for confusion.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added hostname because imagine we are running in something like kubernets. In that scenario when sending emails we should use the web address not the internal IP.

But I see the name is more confusing, we should rename it to something like siteUrl which makes more sense

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, we can rename it and when this PR will be merged I'll resolve conflicts in second one.

}

transporter.sendMail(mailOptions, function (error, info) {
Expand All @@ -83,14 +63,10 @@ userSchema.post('save', async function saved (doc, next) {
})

userSchema.method({
transform () {
transform: function () {
const transformed = {}
const fields = ['id', 'name', 'email', 'createdAt', 'role']

fields.forEach((field) => {
transformed[field] = this[field]
})

const fields = ['id', 'name', 'email', 'createdAt']
fields.forEach(field => { transformed[field] = this[field] })
return transformed
},

Expand All @@ -99,39 +75,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)

if (!user.active) throw new APIError(`User not activated`, httpStatus.UNAUTHORIZED)

return user
}
}
userSchema.statics = { checkDuplicateEmailError }

module.exports = mongoose.model('User', userSchema)
20 changes: 20 additions & 0 deletions src/models/utils/checkDuplicateEmailError.js
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions src/models/utils/findAndGenerateToken.js
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions src/models/utils/hashPass.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use strict'

const bcrypt = require('bcrypt-nodejs')

const hashPass = (schema) => {
schema.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)
}
})
}

module.exports = hashPass
11 changes: 11 additions & 0 deletions src/routes/api/admin.route.js
Original file line number Diff line number Diff line change
@@ -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
Loading