feat: add registration flow with email verification

This commit is contained in:
Martin Grotz 2020-10-20 21:25:00 +02:00 committed by uwewoessner
parent 3fb02b5809
commit b3d9fb3137
15 changed files with 454 additions and 89 deletions

View file

@ -28,3 +28,10 @@ Uploading a track to the local server requires multiple steps, as uploading is n
- In each of the three requests add your user id in the "Pre-request script" tab as the value for the "UserId" variable
- As tracks have to be split into smaller parts to get a working upload from the sensor you have to run the three requests in the order of: begin -> add -> end
- View your freshly uploaded track at (http://localhost:4200) -> Home -> Your feed
### Sending E-Mails
By default in development mode mails are not sent, but instead the mail data is logged to the console. This can be overriden with the `--devSendMails` flag if you start the application like so: `npm run dev -- --devSendMails`.
Mails are also always sent in production mode!
For actually sending e-mails the user and password for the SMTP server need to be specified as environment variables. The username is read from `MAILUSER`, and the password is read from `MAILPW`, so in local development startup would like something like this (at least in Linux): `MAILUSER=myuser MAILPW=supersecurepassword npm run dev -- --devSendMails`.

15
_helpers/send-email.js Normal file
View file

@ -0,0 +1,15 @@
const nodemailer = require('nodemailer');
const config = require('../config/email');
module.exports = sendEmail;
async function sendEmail({ to, subject, html, from = config.emailFrom }) {
if (config.sendMails) {
const transporter = nodemailer.createTransport(config.smtpOptions);
await transporter.sendMail({ from, to, subject, html });
} else {
console.log({
to, subject, html, from
});
}
}

View file

@ -0,0 +1,19 @@
module.exports = errorHandler;
function errorHandler(err, req, res, next) {
switch (true) {
case typeof err === 'string':
// custom application error
const is404 = err.toLowerCase().endsWith('not found');
const statusCode = is404 ? 404 : 400;
return res.status(statusCode).json({ message: err });
case err.name === 'ValidationError':
// mongoose validation error
return res.status(400).json({ message: err.message });
case err.name === 'UnauthorizedError':
// jwt authentication error
return res.status(401).json({ message: 'Unauthorized' });
default:
return res.status(500).json({ message: err.message });
}
}

View file

@ -0,0 +1,19 @@
module.exports = validateRequest;
function validateRequest(req, next, schema) {
console.log('validateRequest');
const options = {
abortEarly: false, // include all errors
allowUnknown: true, // ignore unknown props
stripUnknown: true // remove unknown props
};
const { error, value } = schema.validate(req.body, options);
if (error) {
console.log('error: ', error)
next(`Validation error: ${error.details.map(x => x.message).join(', ')}`);
} else {
req.body = value;
next();
}
}

149
accounts/account.service.js Normal file
View file

@ -0,0 +1,149 @@
const crypto = require("crypto");
const mongoose = require('mongoose');
const sendEmail = require('../_helpers/send-email');
var User = mongoose.model('User');
module.exports = {
register,
verifyEmail,
forgotPassword,
validateResetToken,
resetPassword,
};
async function register(params, origin) {
const user = await User.findOne({ email: params.email });
if (user) {
// send already registered error in email to prevent account enumeration
return await sendAlreadyRegisteredEmail(params.email, origin);
}
const newUser = new User();
newUser.username = params.username;
newUser.email = params.email;
newUser.setPassword(params.password);
newUser.verificationToken = randomTokenString();
newUser.needsEmailValidation = true;
await newUser.save();
// send email
await sendVerificationEmail(newUser, origin);
}
async function verifyEmail({ token }) {
const account = await User.findOne({ verificationToken: token });
if (!account) throw 'Verification failed';
account.needsEmailValidation = false;
account.verificationToken = undefined;
await account.save();
}
async function forgotPassword({ email }, origin) {
const account = await User.findOne({ email });
console.log('forgotPassword', account, email);
// always return ok response to prevent email enumeration
if (!account) return;
// create reset token that expires after 24 hours
account.resetToken = {
token: randomTokenString(),
expires: new Date(Date.now() + 24 * 60 * 60 * 1000)
};
await account.save();
console.log('forgotPassword account saved', account);
// send email
await sendPasswordResetEmail(account, origin);
}
async function validateResetToken({ token }) {
const account = await User.findOne({
'resetToken.token': token,
'resetToken.expires': { $gt: Date.now() }
});
if (!account) throw 'Invalid token';
}
async function resetPassword({ token, password }) {
const account = await User.findOne({
'resetToken.token': token,
'resetToken.expires': { $gt: Date.now() }
});
if (!account) throw 'Invalid token';
// update password and remove reset token
account.setPassword(password)
account.resetToken = undefined;
await account.save();
}
function randomTokenString() {
return crypto.randomBytes(40).toString('hex');
}
async function sendVerificationEmail(account, origin) {
let message;
if (origin) {
const verifyUrl = `${origin}/account/verify-email?token=${account.verificationToken}`;
message = `<p>Please click the below link to verify your email address:</p>
<p><a href="${verifyUrl}">${verifyUrl}</a></p>`;
} else {
message = `<p>Please use the below token to verify your email address with the <code>/account/verify-email</code> api route:</p>
<p><code>${account.verificationToken}</code></p>`;
}
await sendEmail({
to: account.email,
subject: 'Sign-up Verification API - Verify Email',
html: `<h4>Verify Email</h4>
<p>Thanks for registering!</p>
${message}`
});
}
async function sendAlreadyRegisteredEmail(email, origin) {
let message;
if (origin) {
message = `<p>If you don't know your password please visit the <a href="${origin}/account/forgot-password">forgot password</a> page.</p>`;
} else {
message = `<p>If you don't know your password you can reset it via the <code>/account/forgot-password</code> api route.</p>`;
}
await sendEmail({
to: email,
subject: 'Sign-up Verification API - Email Already Registered',
html: `<h4>Email Already Registered</h4>
<p>Your email <strong>${email}</strong> is already registered.</p>
${message}`
});
}
async function sendPasswordResetEmail(account, origin) {
let message;
if (origin) {
const resetUrl = `${origin}/account/reset-password?token=${account.resetToken.token}`;
message = `<p>Please click the below link to reset your password, the link will be valid for 1 day:</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>`;
} else {
message = `<p>Please use the below token to reset your password with the <code>/account/reset-password</code> api route:</p>
<p><code>${account.resetToken.token}</code></p>`;
}
await sendEmail({
to: account.email,
subject: 'Sign-up Verification API - Reset Password',
html: `<h4>Reset Password Email</h4>
${message}`
});
}

View file

@ -0,0 +1,88 @@
const express = require('express');
const router = express.Router();
const Joi = require('joi');
const validateRequest = require('../_middleware/validate-request');
const accountService = require('./account.service');
// routes
router.post('/register', registerSchema, register);
router.post('/verify-email', verifyEmailSchema, verifyEmail);
router.post('/forgot-password', forgotPasswordSchema, forgotPassword);
router.post('/validate-reset-token', validateResetTokenSchema, validateResetToken);
router.post('/reset-password', resetPasswordSchema, resetPassword);
module.exports = router;
function registerSchema(req, res, next) {
const schema = Joi.object({
username: Joi.string().required(),
email: Joi.string().email().required(),
password: Joi.string().min(6).required(),
confirmPassword: Joi.string().valid(Joi.ref('password')).required()
});
validateRequest(req, next, schema);
}
function register(req, res, next) {
accountService.register(req.body, req.get('origin'))
.then(() => res.json({ message: 'Registration successful, please check your email for verification instructions' }))
.catch((err) => {
console.log(err);
next(err)
});
}
function verifyEmailSchema(req, res, next) {
const schema = Joi.object({
token: Joi.string().required()
});
validateRequest(req, next, schema);
}
function verifyEmail(req, res, next) {
accountService.verifyEmail(req.body)
.then(() => res.json({ message: 'Verification successful, you can now login' }))
.catch(next);
}
function forgotPasswordSchema(req, res, next) {
const schema = Joi.object({
email: Joi.string().email().required()
});
validateRequest(req, next, schema);
}
function forgotPassword(req, res, next) {
accountService.forgotPassword(req.body, req.get('origin'))
.then(() => res.json({ message: 'Please check your email for password reset instructions' }))
.catch(next);
}
function validateResetTokenSchema(req, res, next) {
const schema = Joi.object({
token: Joi.string().required()
});
validateRequest(req, next, schema);
}
function validateResetToken(req, res, next) {
accountService.validateResetToken(req.body)
.then(() => res.json({ message: 'Token is valid' }))
.catch(next);
}
function resetPasswordSchema(req, res, next) {
const schema = Joi.object({
token: Joi.string().required(),
password: Joi.string().min(6).required(),
confirmPassword: Joi.string().valid(Joi.ref('password')).required()
});
validateRequest(req, next, schema);
}
function resetPassword(req, res, next) {
accountService.resetPassword(req.body)
.then(() => res.json({ message: 'Password reset successful, you can now login' }))
.catch(next);
}

14
app.js
View file

@ -24,7 +24,7 @@ app.use(bodyParser.json());
app.use(require('method-override')());
app.use(express.static(__dirname + '/public'));
app.use(session({ secret: 'conduit', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
app.use(session({ secret: 'obsobs', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
if (!isProduction) {
app.use(errorhandler());
@ -62,10 +62,12 @@ if (!isProduction) {
res.status(err.status || 500);
res.json({'errors': {
res.json({
'errors': {
message: err.message,
error: err
}});
}
});
});
}
@ -73,10 +75,12 @@ if (!isProduction) {
// no stacktraces leaked to user
app.use(function (err, req, res, next) {
res.status(err.status || 500);
res.json({'errors': {
res.json({
'errors': {
message: err.message,
error: {}
}});
}
});
});
// finally, let's start our server...

15
config/email.js Normal file
View file

@ -0,0 +1,15 @@
const isProduction = process.env.NODE_ENV === 'production';
const forcedMail = process.argv.findIndex(s => s === '--devSendMails') !== -1;
module.exports = {
"sendMails": isProduction || forcedMail,
"emailFrom": "noreply@openbikesensor.org",
"smtpOptions": {
"host": "mail.your-server.de",
"port": 587,
"auth": {
"user": process.env.MAILUSER,
"pass": process.env.MAILPW
}
}
};

View file

@ -12,6 +12,10 @@ passport.use(new LocalStrategy({
return done(null, false, { errors: { 'email or password': 'is invalid' } });
}
if (user && user.needsEmailValidation) {
return done(null, false, { errors: { 'E-Mail-Bestätigung': 'noch nicht erfolgt' } });
}
return done(null, user);
}).catch(done);
}));

View file

@ -13,10 +13,16 @@ var UserSchema = new mongoose.Schema({
following: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
areTracksVisibleForAll: Boolean,
hash: String,
salt: String
salt: String,
needsEmailValidation: Boolean,
verificationToken: String,
resetToken: {
token: String,
expires: Date
}
}, { timestamps: true });
UserSchema.plugin(uniqueValidator, {message: 'is already taken.'});
UserSchema.plugin(uniqueValidator, { message: 'ist bereits vergeben. Sorry!' });
UserSchema.methods.validPassword = function (password) {
var hash = crypto.pbkdf2Sync(password, this.salt, 10000, 512, 'sha512').toString('hex');

48
package-lock.json generated
View file

@ -4,6 +4,37 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@hapi/address": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-4.1.0.tgz",
"integrity": "sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ==",
"requires": {
"@hapi/hoek": "^9.0.0"
}
},
"@hapi/formula": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz",
"integrity": "sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A=="
},
"@hapi/hoek": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.1.0.tgz",
"integrity": "sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw=="
},
"@hapi/pinpoint": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.0.tgz",
"integrity": "sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw=="
},
"@hapi/topo": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz",
"integrity": "sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==",
"requires": {
"@hapi/hoek": "^9.0.0"
}
},
"@postman/form-data": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.0.tgz",
@ -1416,6 +1447,18 @@
}
}
},
"joi": {
"version": "17.2.1",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.2.1.tgz",
"integrity": "sha512-YT3/4Ln+5YRpacdmfEfrrKh50/kkgX3LgBltjqnlMPIYiZ4hxXZuVJcxmsvxsdeHg9soZfE3qXxHC2tMpCCBOA==",
"requires": {
"@hapi/address": "^4.1.0",
"@hapi/formula": "^2.0.0",
"@hapi/hoek": "^9.0.0",
"@hapi/pinpoint": "^2.0.0",
"@hapi/topo": "^5.0.0"
}
},
"js-sha512": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha512/-/js-sha512-0.8.0.tgz",
@ -1916,6 +1959,11 @@
"integrity": "sha512-0yggixNfrA1KcBwvh/Hy2xAS1Wfs9dcg6TdFf2zN7gilcAigMdrtZ4ybrBSXBgLvGDw9V1p2MRnGBMq7XjTWLg==",
"dev": true
},
"nodemailer": {
"version": "6.4.14",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.14.tgz",
"integrity": "sha512-0AQHOOT+nRAOK6QnksNaK7+5vjviVvEBzmZytKU7XSA+Vze2NLykTx/05ti1uJgXFTWrMq08u3j3x4r4OE6PAA=="
},
"nodemon": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.4.tgz",

View file

@ -22,6 +22,7 @@
"express": "4.17.1",
"express-jwt": "^6.0.0",
"express-session": "1.17.1",
"joi": "^17.2.1",
"jsonwebtoken": "8.5.1",
"latest": "^0.2.0",
"method-override": "3.0.0",
@ -29,6 +30,7 @@
"mongoose": "^5.10.7",
"mongoose-unique-validator": "2.0.3",
"morgan": "1.10.0",
"nodemailer": "^6.4.14",
"passport": "0.4.1",
"passport-local": "1.0.0",
"request": "2.88.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View file

@ -4,6 +4,7 @@ router.use('/', require('./users'));
router.use('/profiles', require('./profiles'));
router.use('/tracks', require('./tracks'));
router.use('/tags', require('./tags'));
router.use('/accounts', require('../../accounts/accounts.controller'));
router.use(function (err, req, res, next) {
if (err.name === 'ValidationError') {

View file

@ -63,16 +63,4 @@ router.post('/users/login', function(req, res, next){
})(req, res, next);
});
router.post('/users', function(req, res, next){
var user = new User();
user.username = req.body.user.username;
user.email = req.body.user.email;
user.setPassword(req.body.user.password);
user.save().then(function(){
return res.json({user: user.toAuthJSON()});
}).catch(next);
});
module.exports = router;