/* 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 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'); // 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 = webServer.instance.buildUrl( `/_internal/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: /^\/_internal\/reset_password\?token=[a-f0-9]+$/, handler: WebPasswordReset.routeResetPasswordGet, }, // POST handler for performing the actual reset { method: 'POST', path: /^\/_internal\/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 = webServer.instance.buildUrl('/_internal/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;