Iworb's blog

Full Stack (Node + Express + MongoDb + Vue + Nuxt) application. Part 6: Authentication

2018-02-15

I highly recommend to use Postman to test your REST api, especially in this part.
Let’s get started with authentication with installing passport.js and some requirements:

1
yarn add passport passport-local

Make an auth directory in your engine. Passport has different strategies to authenticate and it could be split in separate files.
It should be init after our session, but before router:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = db => {
const app = express()

initMiddleware(app)
initHelmetHeaders(app)
initSession(app, db)

require('./auth')(app) // Here
require('@routes')(app, initNuxt)

return app
}

Now we can should handle this in server/engine/auth/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const logger = require('@engine/logger')
const User = require('@models/user')

const passport = require('passport')
const chalk = require('chalk')

module.exports = app => {
app.use(passport.initialize())
app.use(passport.session())

passport.serializeUser((user, done) => {
done(null, user.id)
})

passport.deserializeUser(function (id, done) {
User.findById(id, {'local.password': 0})
.then(user => {
// Check that the user is not disabled or deleted
if (user.status !== 1) return done(null, false)
return done(null, user)
})
.catch(done)
})

logger.info(chalk.bold('Passport strategies initialization...'))
// [1]
const strategies = ['local']
strategies.forEach(strategy => {
logger.info(`Loading ${strategy} passport strategy`)
require(`./strategies/${strategy}`)
})
}

Skip default passport initialization, let’s talk about strategies. Currently I’ve made strategies directory and create local strategy (description below) and load it in [1]. Yes, I could read filesystem, or create another index file to export all strategies I have, but there’s no reason to do that. Strategies changes quite seldom and we could handle this by adding it names in the strategies array of file names.
Local login strategy is simple:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const c = require('@engine/constants')
const User = require('@models/user')
const {FieldsError} = require('@engine/errors')

const passport = require('passport')
const LocalStrategy = require('passport-local').Strategy

passport.use('local', new LocalStrategy({
usernameField: 'username',
passwordField: 'password',
passReqToCallback: true
}, async (req, username, password, done) => {
try {
const user = await User.findOne({
$or: [
{name: username},
{'local.email': username}
]
})
if (!user) return done(new FieldsError({username: c.E.NOT_FOUND}))
if (user.status !== 1) return done(new FieldsError({username: c.E.DISABLED}))
if (!(await user.verifyPassword(password))) return done(new FieldsError({password: c.E.INVALID}))
return done(null, user)
} catch (err) {
return done(err)
}
}))

Just check user existance, his status and password.
You may see new FieldsError class. It’s been made for throwing Validation-like errors.
All custom errors will take their place at server/engine/errors directory. Here’s fieldsError.js:

1
2
3
4
5
6
7
8
module.exports = class FormError extends Error {
constructor (fields, message, status) {
super(message)
this.status = status || 400
this.fields = fields
Error.captureStackTrace(this, this.constructor)
}
}

And index.js placed nearby:

1
2
3
4
5
const FieldsError = require('./fieldsError')

module.exports = {
FieldsError
}

We described strategies, so let’s describe routes for auth in server/routes/auth.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const c = require('@engine/constants')
const response = require('@engine/response')

const passport = require('passport')
const router = require('express').Router()

router.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) return next(err)
if (!user) return response.json(res, null, response.BAD_REQUEST, c.E.NOT_FOUND)
req.login(user, err => {
if (err) return next(err)
const u = user.toObject()
if (u.local && u.local.password) delete u.local.password
return res.json(u)
})
})(req, res, next)
})

router.post('/logout', (req, res) => {
req.logout()
res.redirect('/')
})

module.exports = router

Finally, add it to routes index and don’t forget about our custom errors:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
const EngineErrors = require('@engine/errors')
...
module.exports = (app, ...middlewares) => {
app.use(require('./auth'))

router.use('/users', require('./user'))
...
} else if (err instanceof EngineErrors.FieldsError) {
return response.json(res, null, response.BAD_REQUEST, null, {fields: err.fields})
} else {
return response.json(res, null, response.SERVER_ERROR, err.message)
}
...

Also, we can invalidate user update if new password and confirmation doesn’t match (user model):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const {FieldsError} = require('@engine/errors')
...
userSchema.statics.updateUser = function (req, res, next) {
let values = _.pick(req.body, _.keys(userSchema.paths))
if (values._id) delete values._id
values = _.pickBy(flatten(values), _.identity)
if (
((req.body.passNew && req.body.passNew.length) ||
(req.body.passNewConfirm && req.body.passNewConfirm.length)) &&
req.body.passNew !== req.body.passNewConfirm) {
return next(new FieldsError({passNew: c.E.NOT_MATCH, passNewConfirm: c.E.NOT_MATCH}))
}
return this.findById(req.params.id)
.then(user => {
if (req.body.passNew) {
if (_.isUndefined(values['local.password'])) {
throw user.invalidate('local.password', c.E.REQUIRED)
}
if (!user.verifyPasswordSync(values['local.password'])) {
throw user.invalidate('local.password', c.E.NOT_VALID)
}
values['local.password'] = req.body.passNew
}
return user.update(values)
})
.then(() => {
return res.json(true)
})
.catch(next)
}

Finally, let’s make some middleware helpers (server/engine/auth/helpers.js):

1
2
3
4
module.exports.isAuthenticated = (req, res, next) => {
if (req.isAuthenticated()) return next()
else res.sendStatus(401)
}

Currently anyone can get users list and information about any user, even delete or modify some fields. Let’s improve this:

  • List of all users - limit for all list requests, retrieve names only;
  • User details - no changes;
  • User create - no changes;
  • User edit - only owner (currently) should have permissions to edit;
  • User delete - noone (currently) should have permissions to delete user.
    Let’s make some new routes for this purpose:
  • post /signup - same as post to /users, create new user;
  • get /users/me - same as get /users/:id, but id is current user id;
  • patch /users/me - same as patch /users/:id, but id is current user id;
    First of all, let’s limit of user list retrieve (server/routes/user.js):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    router.get('/', (req, res, next) => {
    const limit = req.body.limit || req.query.limit || 50
    let page = req.body.page || req.query.page || 1
    let total
    if (page < 1) page = 1
    page--
    User.count()
    .then(c => {
    total = c
    return User.find({}, {name: 1}).limit(limit).skip(limit * page)
    })
    .then(users => {
    return res.json({
    total: total,
    items: users
    })
    })
    .catch(next)
    })

To achieve /signup behaviour we have to move our user creation logic from router to model:

1
2
3
4
5
6
7
8
9
userSchema.statics.createUser = function (req, res, next) {
let values = _.pick(req.body, _.keys(userSchema.paths))
if (values._id) delete values._id
values = _.pickBy(flatten(values), _.identity)
const newUser = new this(values)
return newUser.save().then(user => {
return response.json(res, _.omit(user.toObject(), 'local.password'))
}).catch(next)
}

server/routes/user.js changes:

1
router.post('/', User.createUser.bind(User))

server/routes/auth.js:

1
router.post('/signup', User.createUser.bind(User))

To get /users/me we should define handler before /users/:id, otherwise it will try to fetch user with id='me':

1
2
3
4
5
const {isAuthenticated} = require('@engine/auth/helpers')
...
router.get('/me', isAuthenticated, (req, res, next) => {
return res.json(req.user.toObject())
})

Patch /users/me looks similar, deleting user now will throw error:

1
2
3
4
5
6
7
8
9
10
11
12
router.patch('/me', isAuthenticated, User.updateUser.bind(User))

router.patch('/:id', isAuthenticated, User.updateUser.bind(User))

router.delete('/:id', (req, res, next) => {
return response(res, null, response.REQUEST_FAILED)
// User.findByIdAndRemove(req.params.id)
// .then(() => {
// return res.json(true)
// })
// .catch(next)
})

And the last thing is modifying updateUser function because we should handle new route:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
userSchema.statics.updateUser = async function (req, res, next) {
let values = _.pick(req.body, _.keys(userSchema.paths))
if (values._id) delete values._id
values = _.pickBy(flatten(values), _.identity)
if (
((req.body.passNew && req.body.passNew.length) ||
(req.body.passNewConfirm && req.body.passNewConfirm.length)) &&
req.body.passNew !== req.body.passNewConfirm) {
return next(new FieldsError({passNew: c.E.NOT_MATCH, passNewConfirm: c.E.NOT_MATCH}))
}
try {
const user = req.params.id ? await this.findById(req.params.id) : req.user
if (user._id !== req.user._id) return response.json(res, null, response.FORBIDDEN, c.E.DENIED)
if (req.body.passNew) {
if (_.isUndefined(values['local.password'])) {
return next(user.invalidate('local.password', c.E.REQUIRED))
}
if (!user.verifyPasswordSync(values['local.password'])) {
return next(user.invalidate('local.password', c.E.NOT_VALID))
}
values['local.password'] = req.body.passNew
}
await user.update(values)
return res.json(true)
} catch (err) {
return next(err)
}
}

Now user can edit only himself.
NB: there’s some more constants added into server/engine/constants.js:

1
2
3
4
'DENIED': 'E_DENIED',
'DISABLED': 'E_DISABLED',
'INVALID': 'E_INVALID',
'NOT_FOUND': 'E_NOT_FOUND',

This part wraps up 06-auth git branch.