Email vérification et password reset avec FeathersJS

Feathers est livré par défaut avec un système d’authentification locale ou OAuth mais malheureusement il n’y a de base aucune gestion de la vérification de l’email ou encore de procédure de réinitialisation du mot de passe. Bien que cela devrait changer dans le futur (https://www.npmjs.com/package/feathers-authentication-management) j’ai implémenté ma propre solution pour gérer ces process.

Pour commencer, j’ai modifié l’user-model afin d’y rajouter de nouvelles propriétés (isVerified, verifyToken, expireToken, passToken, expirePassToken) :

'use strict';
// user-model.js - A mongoose model
//
// See http://mongoosejs.com/docs/models.html
// for more of what you can do here.
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const globalHooks = require('../../hooks');
const userSchema = new Schema({
email: {type: String, required: true, unique: true},
password: { type: String, required: true },

createdAt: { type: Date, 'default': Date.now },
updatedAt: { type: Date, 'default': Date.now },
  isVerified: { type: Boolean, 'default': false },
verifyToken: { type: String, 'default': globalHooks.token() },
expireToken: { type: Date, 'default': (Date.now() + (1000 * 60 * 60 * 48)) },
  passToken: { type: String },
expirePassToken: { type: Date }
});
const userModel = mongoose.model('user', userSchema);
module.exports = userModel;

Lors de l’enregistrement d’un nouvel utilisateur, isVerified est initialisé à false, un token est assigné à verifyToken et une date d’expiration (48h) à expireToken.

Le token est généré via un hook global :

exports.token = function() {
const charList = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
var s = '';

for(var i = 0; i < 35; i++) {
if(Math.floor(Math.random()*5) < 2) {
s += Math.floor(Math.random()*10).toString();
} else {
s += charList[Math.floor(Math.random()*26)];
}
}

return s;
};

On rajoute au service user un hook “after create” afin d’envoyer par mail un lien avec le token pour valider l’adresse :

exports.after = {
all: [
hooks.remove('password'),
hooks.remove('verifyToken')
],
find: [],
get: [],
create: function(hook) {
globalHooks.email({
address: hook.result.email,
id: hook.result._id,
vtoken: hook.result.verifyToken
});
},
update: [],
patch: [],
remove: []
};

L’envoi du mail est géré par un hook global :

exports.email = function(params) {
if(!params && !params.address) return false;
 const SparkPost = require('sparkpost');
const sparky = new SparkPost('...');

sparky.transmissions.send({
options: {
sandbox: false
},
content: {
from: {
name: 'Eddy Bordi',
email: '...'
},
subject: 'Confirmation de votre adresse email',
html:'<html><body><a href="http://192.168.0.48:3030/verify/users/'+params.id+'/'+params.vtoken+'">Cliquez-ici pour valider votre email</a></body></html>'
},
recipients: [
{address: params.address}
]
})
.then(data => {
console.log('Woohoo! You just sent your first mailing!');
console.log(data);
})
.catch(err => {
console.log('Whoops! Something went wrong');
console.log(err);
});
};

Ici j’ai fait le choix de passer par le service sparkpost pour tout ce qui est email transactionnel.

J’ai créé ensuite un nouveau service “userVerifyService” qui est appelé lorsque l’utilisateur clique sur le lien de validation de son email :

'use strict';
const service = require('feathers-mongoose');
const hooks = require('feathers-hooks');
const globalHooks = require('../../hooks');
class Service {
find(params) {
return Promise.resolve([]);
}
}
module.exports = function() {
const app = this;
  app.use('/verify/:id/:vtoken', new Service());
  const userVerifyService = app.service('/verify/:id/:vtoken');
const userService = app.service('/users');
  userVerifyService.before({
find: function(hook) {
return userService.find({ query: { _id: hook.params.id, verifyToken: hook.params.vtoken, isVerified: false } })
.then(function(res) {
res = res.data;
if(res.length === 1 && res[0].expireToken > new Date()) hook.msg = 'success';
else if(res.length === 1) hook.msg = 'expired';
else hook.msg = 'invalid';
})
.catch(function(err) {
console.log('error', err);
});
},
});
userVerifyService.after({
find: function(hook) {
let msg = 'invalid';
if(hook.msg) msg = hook.msg;
hook.result = { msg: msg };
      if(msg === 'expired') {
userService.patch({ _id: hook.params.id }, { verifyToken: globalHooks.token(), expireToken: (Date.now() + (1000 * 60 * 60 * 48)) })
.then(function(res) {
globalHooks.email({
address: res.email,
id: res._id,
vtoken: res.verifyToken
});
})
.catch(function(err) {
console.log('error', err);
});
}
else if(msg === 'success') {
userService.patch({ _id: hook.params.id }, { isVerified: true, verifyToken: '' })
.then(function(res) {
globalHooks.email({
address: res.email,
verified: true
});
})
.catch(function(err) {
console.log('error', err);
});
}
}
});
}
module.exports.Service = Service;

Un hook dans le service authentication permet d’empêcher les users non vérifiés de se connecter :

  authService.hooks({
after(hook) {
if(hook.result.data && hook.result.data.isVerified) hook.result = { token: hook.result.token };
else hook.result = { error: 'not verified' }
}
});

Enfin, pour gérer la réinitialisation du mot de passe, j’ai créé un service resetPassword :

'use strict';
const service = require('feathers-mongoose');
const hooks = require('feathers-hooks');
const globalHooks = require('../../hooks');
const auth = require('feathers-authentication').hooks;
class Service {
find(params) {
return Promise.resolve([]);
}
create(data, params) {
if(Array.isArray(data)) {
return Promise.all(data.map(current => this.create(current)));
}
    return Promise.resolve(data);
}
}
module.exports = function() {
const app = this;
  app.use('/reset/:id', new Service());
  const newPassService = app.service('/reset/:id');
const userService = app.service('/users');
  newPassService.before({
create: [auth.hashPassword()]
});
  newPassService.before({
find: function(hook) {
return userService.find({ query: { _id: hook.params.id } })
.then(function(res) {
res = res.data;
if(res.length === 1 && (!res[0].expirePassToken || res[0].expirePassToken <= new Date())) hook.msg = 'success';
else if(res.length === 1) hook.msg = 'sent';
else hook.msg = 'invalid';
})
.catch(function(err) {
console.log('error', err);
});
},
  create: function(hook) {
return userService.find({ query: { _id: hook.params.id } })
.then(function(res) {
res = res.data;
if(hook.data.password && hook.data.ptoken && res[0].passToken && hook.data.ptoken === res[0].passToken && res.length === 1 && res[0].expirePassToken && res[0].expirePassToken > new Date()) hook.msg = 'success';
else if(hook.data.password && hook.data.ptoken && res[0].passToken && hook.data.ptoken === res[0].passToken && res.length === 1) hook.msg = 'expired';
else hook.msg = 'invalid';
})
.catch(function(err) {
console.log('error', err);
});
}
});
  newPassService.after({
find: function(hook) {
let msg = 'invalid';
if(hook.msg) msg = hook.msg;
hook.result = { msg: msg };
      if(msg === 'success') {
userService.patch({ _id: hook.params.id }, { passToken: globalHooks.token(), expirePassToken: (Date.now() + (1000 * 60 * 60 * 2)) })
.then(function(res) {
globalHooks.email({
address: res.email,
id: res._id,
ptoken: res.passToken
});
})
.catch(function(err) {
console.log('error', err);
});
}
},
    create: function(hook) {
let msg = 'invalid';
if(hook.msg) msg = hook.msg;
hook.result = { msg: msg };
      if(msg === 'success') {
userService.patch({ _id: hook.params.id }, { password: hook.data.password, passToken: '' })
.then(function(res) {
globalHooks.email({
address: res.email,
newpass: true
});
})
.catch(function(err) {
console.log('error', err);
});
}
}
});
}
module.exports.Service = Service;