enigma-bbs/core/web_password_reset.js

387 lines
14 KiB
JavaScript

/* jslint node: true */
'use strict';
// ENiGMA½
const Config = require('./config.js').get;
const Errors = require('./enig_error.js').Errors;
const getServer = require('./listening_server.js').getServer;
const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName;
const { WellKnownLocations } = require('./servers/content/web');
const User = require('./user.js');
const userDb = require('./database.js').dbs.user;
const getISOTimestampString = require('./database.js').getISOTimestampString;
const Log = require('./logger.js').log;
const UserProps = require('./user_property.js');
const { buildUrl } = require('./web_util');
// deps
const async = require('async');
const crypto = require('crypto');
const fs = require('graceful-fs');
const url = require('url');
const querystring = require('querystring');
const _ = require('lodash');
const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT = `%USERNAME%:
A password reset has been requested for your account on %BOARDNAME%.
* If this was not you, please ignore this email.
* Otherwise, follow this link: %RESET_URL%
`;
function getWebServer() {
return getServer(webServerPackageName);
}
class WebPasswordReset {
static startup(cb) {
WebPasswordReset.registerRoutes(err => {
return cb(err);
});
}
static sendForgotPasswordEmail(username, cb) {
const webServer = getServer(webServerPackageName);
if (!webServer || !webServer.instance.isEnabled()) {
return cb(Errors.General('Web server is not enabled'));
}
async.waterfall(
[
function getEmailAddress(callback) {
if (!username) {
return callback(Errors.MissingParam('Missing "username"'));
}
User.getUserIdAndName(username, (err, userId) => {
if (err) {
return callback(err);
}
User.getUser(userId, (err, user) => {
if (err || !user.properties[UserProps.EmailAddress]) {
return callback(
Errors.DoesNotExist(
'No email address associated with this user'
)
);
}
return callback(null, user);
});
});
},
function generateAndStoreResetToken(user, callback) {
//
// Reset "token" is simply HEX encoded cryptographically generated bytes
//
crypto.randomBytes(256, (err, token) => {
if (err) {
return callback(err);
}
token = token.toString('hex');
const newProperties = {
[UserProps.EmailPwResetToken]: token,
[UserProps.EmailPwResetTokenTs]: getISOTimestampString(),
};
// we simply place the reset token in the user's properties
user.persistProperties(newProperties, err => {
return callback(err, user);
});
});
},
function getEmailTemplates(user, callback) {
const config = Config();
fs.readFile(
config.contentServers.web.resetPassword.resetPassEmailText,
'utf8',
(err, textTemplate) => {
if (err) {
textTemplate = PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT;
}
fs.readFile(
config.contentServers.web.resetPassword
.resetPassEmailHtml,
'utf8',
(err, htmlTemplate) => {
return callback(
null,
user,
textTemplate,
htmlTemplate
);
}
);
}
);
},
function buildAndSendEmail(user, textTemplate, htmlTemplate, callback) {
const sendMail = require('./email.js').sendMail;
const resetUrl = buildUrl(
WellKnownLocations.Internal +
`/sec/reset_password?token=${
user.properties[UserProps.EmailPwResetToken]
}`
);
function replaceTokens(s) {
return s
.replace(/%BOARDNAME%/g, Config().general.boardName)
.replace(/%USERNAME%/g, user.username)
.replace(
/%TOKEN%/g,
user.properties[UserProps.EmailPwResetToken]
)
.replace(/%RESET_URL%/g, resetUrl);
}
textTemplate = replaceTokens(textTemplate);
if (htmlTemplate) {
htmlTemplate = replaceTokens(htmlTemplate);
}
const message = {
to: `${user.properties[UserProps.RealName] || user.username} <${
user.properties[UserProps.EmailAddress]
}>`,
// from will be filled in
subject: 'Forgot Password',
text: textTemplate,
html: htmlTemplate,
};
sendMail(message, (err, info) => {
if (err) {
Log.warn(
{ error: err.message },
'Failed sending password reset email'
);
} else {
Log.info(
{ info: info },
'Successfully sent password reset email'
);
}
return callback(err);
});
},
],
err => {
return cb(err);
}
);
}
static scheduleEvents(cb) {
// :TODO: schedule ~daily cleanup task
return cb(null);
}
static registerRoutes(cb) {
const webServer = getWebServer();
if (!webServer) {
return cb(null); // no webserver enabled
}
if (!webServer.instance.isEnabled()) {
return cb(null); // no error, but we're not serving web stuff
}
[
{
// this is the page displayed to user when they GET it
method: 'GET',
path: /^\/_enig\/sec\/reset_password\?token=[a-f0-9]+$/,
handler: WebPasswordReset.routeResetPasswordGet,
},
// POST handler for performing the actual reset
{
method: 'POST',
path: /^\/_enig\/sec\/reset_password$/,
handler: WebPasswordReset.routeResetPasswordPost,
},
].forEach(r => {
webServer.instance.addRoute(r);
});
return cb(null);
}
static fileNotFound(webServer, resp) {
return webServer.instance.fileNotFound(resp);
}
static accessDenied(webServer, resp) {
return webServer.instance.accessDenied(resp);
}
static getUserByToken(token, cb) {
async.waterfall(
[
function validateToken(callback) {
User.getUserIdsWithProperty(
'email_password_reset_token',
token,
(err, userIds) => {
if (userIds && userIds.length === 1) {
return callback(null, userIds[0]);
}
return callback(
Errors.Invalid('Invalid password reset token')
);
}
);
},
function getUser(userId, callback) {
User.getUser(userId, (err, user) => {
return callback(null, user);
});
},
],
(err, user) => {
return cb(err, user);
}
);
}
static routeResetPasswordGet(req, resp) {
const webServer = getWebServer(); // must be valid, we just got a req!
const urlParts = url.parse(req.url, true);
const token = urlParts.query && urlParts.query.token;
if (!token) {
return WebPasswordReset.accessDenied(webServer, resp);
}
WebPasswordReset.getUserByToken(token, (err, user) => {
if (err) {
// assume it's expired
return webServer.instance.respondWithError(
resp,
410,
'Invalid or expired reset link.',
'Expired Link'
);
}
const postResetUrl = buildUrl(
WellKnownLocations.Internal + '/sec/reset_password'
);
const config = Config();
return webServer.instance.routeTemplateFilePage(
config.contentServers.web.resetPassword.resetPageTemplate,
(templateData, preprocessFinished) => {
const finalPage = templateData
.replace(/%BOARDNAME%/g, config.general.boardName)
.replace(/%USERNAME%/g, user.username)
.replace(/%TOKEN%/g, token)
.replace(/%RESET_URL%/g, postResetUrl);
return preprocessFinished(null, finalPage);
},
resp
);
});
}
static routeResetPasswordPost(req, resp) {
const webServer = getWebServer(); // must be valid, we just got a req!
let bodyData = '';
req.on('data', data => {
bodyData += data;
});
function badRequest() {
return webServer.instance.respondWithError(
resp,
400,
'Bad Request.',
'Bad Request'
);
}
req.on('end', () => {
const formData = querystring.parse(bodyData);
const config = Config();
if (
!formData.token ||
!formData.password ||
!formData.confirm_password ||
formData.password !== formData.confirm_password ||
formData.password.length < config.users.passwordMin ||
formData.password.length > config.users.passwordMax
) {
return badRequest();
}
WebPasswordReset.getUserByToken(formData.token, (err, user) => {
if (err) {
return badRequest();
}
user.setNewAuthCredentials(formData.password, err => {
if (err) {
return badRequest();
}
// delete assoc properties - no need to wait for completion
user.removeProperties([
UserProps.EmailPwResetToken,
UserProps.EmailPwResetTokenTs,
]);
if (true === _.get(config, 'users.unlockAtEmailPwReset')) {
Log.info(
{ username: user.username, userId: user.userId },
'Remove any lock on account due to password reset policy'
);
user.unlockAccount(() => {
/* dummy */
});
}
resp.writeHead(200);
return resp.end('Password changed successfully');
});
});
});
}
}
function performMaintenanceTask(args, cb) {
const forgotPassExpireTime = args[0] || '24 hours';
// remove all reset token associated properties older than |forgotPassExpireTime|
userDb.run(
`DELETE FROM user_property
WHERE user_id IN (
SELECT user_id
FROM user_property
WHERE prop_name = "email_password_reset_token_ts"
AND DATETIME("now") >= DATETIME(prop_value, "+${forgotPassExpireTime}")
) AND prop_name IN ("email_password_reset_token_ts", "email_password_reset_token");`,
err => {
if (err) {
Log.warn(
{ error: err.message },
'Failed deleting old email reset tokens'
);
}
return cb(err);
}
);
}
exports.WebPasswordReset = WebPasswordReset;
exports.performMaintenanceTask = performMaintenanceTask;