* Bump version to 0.0.5-alpha

* Add email password reset support
This commit is contained in:
Bryan Ashby 2017-02-26 21:28:05 -07:00
parent 97e19957ce
commit f5899bc10f
18 changed files with 571 additions and 28 deletions

View File

@ -243,6 +243,10 @@ function initialize(cb) {
function readyFileAreaWeb(callback) {
return require('./file_area_web.js').startup(callback);
},
function readyPasswordReset(callback) {
const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset;
return WebPasswordReset.startup(callback);
},
function readyEventScheduler(callback) {
const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule;
EventSchedulerModule.loadAndStart( (err, modInst) => {

View File

@ -18,15 +18,26 @@ function ButtonView(options) {
util.inherits(ButtonView, TextView);
ButtonView.prototype.onKeyPress = function(ch, key) {
if(this.isKeyMapped('accept', key.name) || ' ' === ch) {
this.submitData = 'accept';
this.emit('action', 'accept');
delete this.submitData;
} else {
ButtonView.super_.prototype.onKeyPress.call(this, ch, key);
}
};
/*
ButtonView.prototype.onKeyPress = function(ch, key) {
// allow space = submit
if(' ' === ch) {
if(' ' === ch) {
this.emit('action', 'accept');
}
ButtonView.super_.prototype.onKeyPress.call(this, ch, key);
};
*/
ButtonView.prototype.getData = function() {
return null;
return this.submitData || null;
};

View File

@ -230,7 +230,26 @@ function getDefaultConfig() {
domain : 'another-fine-enigma-bbs.org',
staticRoot : paths.join(__dirname, './../www'),
resetPassword : {
//
// The following templates have these variables available to them:
//
// * %BOARDNAME% : Name of BBS
// * %USERNAME% : Username of whom to reset password
// * %TOKEN% : Reset token
// * %RESET_URL% : In case of email, the link to follow for reset. In case of landing page,
// URL to POST submit reset form.
// templates for pw reset *email*
resetPassEmailText : paths.join(__dirname, '../misc/reset_password_email.template.txt'), // plain text version
resetPassEmailHtml : paths.join(__dirname, '../misc/reset_password_email.template.html'), // HTML version
// tempalte for pw reset *landing page*
//
resetPageTemplate : paths.join(__dirname, './../www/reset_password.template.html'),
},
http : {
enabled : false,
port : 8080,
@ -563,6 +582,12 @@ function getDefaultConfig() {
// - @execute:/path/to/something/executable.sh
//
action : '@method:core/message_area.js:trimMessageAreasScheduledEvent',
},
forgotPasswordMaintenance : {
schedule : 'every 24 hours',
action : '@method:core/web_password_reset.js:performMaintenanceTask',
args : [ '24 hours' ] // items older than this will be removed
}
}
},

31
core/email.js Normal file
View File

@ -0,0 +1,31 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const Config = require('./config.js').config;
const Errors = require('./enig_error.js').Errors;
const Log = require('./logger.js').log;
// deps
const _ = require('lodash');
const nodeMailer = require('nodemailer');
exports.sendMail = sendMail;
function sendMail(message, cb) {
if(!_.has(Config, 'email.transport')) {
return cb(Errors.MissingConfig('Email "email::transport" configuration missing'));
}
message.from = message.from || Config.email.defaultFrom;
const transportOptions = Object.assign( {}, Config.email.transport, {
logger : Log,
});
const transport = nodeMailer.createTransport(transportOptions);
transport.sendMail(message, (err, info) => {
return cb(err, info);
});
}

View File

@ -30,6 +30,7 @@ exports.Errors = {
ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode),
MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode),
UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode),
MissingParam : (reason, reasonCode) => new EnigError('Missing paramater(s)', -32008, reason, reasonCode),
};
exports.ErrorReasons = {

View File

@ -13,6 +13,7 @@ const StatLog = require('./stat_log.js');
const User = require('./user.js');
const Log = require('./logger.js').log;
const getConnectionByUserId = require('./client_connections.js').getConnectionByUserId;
const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName;
// deps
const hashids = require('hashids');
@ -23,8 +24,6 @@ const fs = require('fs');
const mimeTypes = require('mime-types');
const _ = require('lodash');
const WEB_SERVER_PACKAGE_NAME = 'codes.l33t.enigma.web.server';
/*
:TODO:
* Load temp download URLs @ startup & set expire timers via scheduler.
@ -51,9 +50,9 @@ class FileAreaWebAccess {
return self.load(callback);
},
function addWebRoute(callback) {
self.webServer = getServer(WEB_SERVER_PACKAGE_NAME);
self.webServer = getServer(webServerPackageName);
if(!self.webServer) {
return callback(Errors.DoesNotExist(`Server with package name "${WEB_SERVER_PACKAGE_NAME}" does not exist`));
return callback(Errors.DoesNotExist(`Server with package name "${webServerPackageName}" does not exist`));
}
if(self.isEnabled()) {
@ -173,6 +172,9 @@ class FileAreaWebAccess {
buildTempDownloadLink(client, fileEntry, hashId) {
hashId = hashId || this.getHashId(client, fileEntry);
return this.webServer.instance.buildUrl(`${Config.fileBase.web.path}${hashId}`);
/*
//
// Create a URL such as
@ -200,6 +202,7 @@ class FileAreaWebAccess {
return `${schema}${Config.contentServers.web.domain}${port}${Config.fileBase.web.path}${hashId}`;
}
*/
}
getExistingTempDownloadServeItem(client, fileEntry, cb) {
@ -246,7 +249,7 @@ class FileAreaWebAccess {
}
fileNotFound(resp) {
this.webServer.instance.respondWithError(resp, 404, 'File not found.', 'File Not Found');
return this.webServer.instance.fileNotFound(resp);
}
routeWebRequestForFile(req, resp) {

View File

@ -47,8 +47,7 @@ class PacketHeader {
point : 0,
};
this.packetVersion = version || '2+';
this.version = version || '2+';
this.origAddress = origAddr || EMPTY_ADDRESS;
this.destAddress = destAddr || EMPTY_ADDRESS;
this.created = createdMoment || moment();
@ -234,7 +233,7 @@ function Packet(options) {
//
// :TODO: adjust values based on version discovered
if(FTN_PACKET_BAUD_TYPE_2_2 === packetHeader.baud) {
packetHeader.packetVersion = '2.2';
packetHeader.version = '2.2';
// See FSC-0045
packetHeader.origPoint = packetHeader.year;
@ -254,14 +253,14 @@ function Packet(options) {
0 != packetHeader.capWord &&
packetHeader.capWord & 0x0001)
{
packetHeader.packetVersion = '2+';
packetHeader.version = '2+';
// See FSC-0048
if(-1 === packetHeader.origNet) {
packetHeader.origNet = packetHeader.auxNet;
}
} else {
packetHeader.packetVersion = '2';
packetHeader.version = '2';
// :TODO: should fill bytes be 0?
}

View File

@ -44,7 +44,9 @@ class Route {
);
}
matchesRequest(req) { return req.method === this.method && this.pathRegExp.test(req.url); }
matchesRequest(req) {
return req.method === this.method && this.pathRegExp.test(req.url);
}
getRouteKey() { return `${this.method}:${this.path}`; }
}
@ -67,6 +69,35 @@ exports.getModule = class WebServerModule extends ServerModule {
}
}
buildUrl(pathAndQuery) {
//
// Create a URL such as
// https://l33t.codes:44512/ + |pathAndQuery|
//
// Prefer HTTPS over HTTP. Be explicit about the port
// only if non-standard. Allow users to override full prefix in config.
//
if(_.isString(Config.contentServers.web.overrideUrlPrefix)) {
return `${Config.contentServers.web.overrideUrlPrefix}${pathAndQuery}`;
}
let schema;
let port;
if(Config.contentServers.web.https.enabled) {
schema = 'https://';
port = (443 === Config.contentServers.web.https.port) ?
'' :
`:${Config.contentServers.web.https.port}`;
} else {
schema = 'http://';
port = (80 === Config.contentServers.web.http.port) ?
'' :
`:${Config.contentServers.web.http.port}`;
}
return `${schema}${Config.contentServers.web.domain}${port}${pathAndQuery}`;
}
isEnabled() {
return this.enableHttp || this.enableHttps;
}
@ -126,7 +157,7 @@ exports.getModule = class WebServerModule extends ServerModule {
}
routeRequest(req, resp) {
const route = _.find(this.routes, r => r.matchesRequest(req) );
const route = _.find(this.routes, r => r.matchesRequest(req) );
return route ? route.handler(req, resp) : this.accessDenied(resp);
}
@ -161,6 +192,10 @@ exports.getModule = class WebServerModule extends ServerModule {
return this.respondWithError(resp, 401, 'Access denied.', 'Access Denied');
}
fileNotFound(resp) {
return this.respondWithError(resp, 404, 'File not found.', 'File Not Found');
}
routeStaticFile(req, resp) {
const fileName = req.url.substr(req.url.indexOf('/', 1));
const filePath = paths.join(Config.contentServers.web.staticRoot, fileName);
@ -168,7 +203,7 @@ exports.getModule = class WebServerModule extends ServerModule {
fs.stat(filePath, (err, stats) => {
if(err) {
return self.respondWithError(resp, 404, 'File not found.', 'File Not Found');
return self.fileNotFound(resp);
}
const headers = {
@ -181,4 +216,28 @@ exports.getModule = class WebServerModule extends ServerModule {
return readStream.pipe(resp);
});
}
routeTemplateFilePage(templatePath, preprocessCallback, resp) {
const self = this;
fs.readFile(templatePath, 'utf8', (err, templateData) => {
if(err) {
return self.fileNotFound(resp);
}
preprocessCallback(templateData, (err, finalPage, contentType) => {
if(err || !finalPage) {
return self.respondWithError(resp, 500, 'Internal Server Error.', 'Internal Server Error');
}
const headers = {
'Content-Type' : contentType || mimeTypes.contentType('.html'),
'Content-Length' : finalPage.length,
};
resp.writeHead(200, headers);
return resp.end(finalPage);
});
});
}
};

View File

@ -5,20 +5,21 @@
const removeClient = require('./client_connections.js').removeClient;
const ansiNormal = require('./ansi_term.js').normal;
const userLogin = require('./user_login.js').userLogin;
const messageArea = require('./message_area.js');
const messageArea = require('./message_area.js');
// deps
const _ = require('lodash');
const iconv = require('iconv-lite');
exports.login = login;
exports.logoff = logoff;
exports.prevMenu = prevMenu;
exports.nextMenu = nextMenu;
exports.prevConf = prevConf;
exports.nextConf = nextConf;
exports.prevArea = prevArea;
exports.nextArea = nextArea;
exports.login = login;
exports.logoff = logoff;
exports.prevMenu = prevMenu;
exports.nextMenu = nextMenu;
exports.prevConf = prevConf;
exports.nextConf = nextConf;
exports.prevArea = prevArea;
exports.nextArea = nextArea;
exports.sendForgotPasswordEmail = sendForgotPasswordEmail;
function login(callingMenu, formData, extraArgs, cb) {
@ -152,3 +153,21 @@ function nextArea(callingMenu, formData, extraArgs, cb) {
return reloadMenu(callingMenu, cb);
});
}
function sendForgotPasswordEmail(callingMenu, formData, extraArgs, cb) {
const username = formData.value.username || callingMenu.client.user.username;
const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset;
WebPasswordReset.sendForgotPasswordEmail(username, err => {
if(err) {
callingMenu.client.log.warn( { err : err.message }, 'Failed sending forgot password email');
}
if(extraArgs.next) {
return callingMenu.gotoMenu(extraArgs.next, cb);
}
return logoff(callingMenu, formData, extraArgs, cb);
});
}

314
core/web_password_reset.js Normal file
View File

@ -0,0 +1,314 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const Config = require('./config.js').config;
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;
// deps
const async = require('async');
const _ = require('lodash');
const crypto = require('crypto');
const fs = require('fs');
const url = require('url');
const querystring = require('querystring');
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.email_address) {
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 = {
email_password_reset_token : token,
email_password_reset_token_ts : getISOTimestampString(),
};
// we simply place the reset token in the user's properties
user.persistProperties(newProperties, err => {
return callback(err, user);
});
});
},
function getEmailTemplates(user, callback) {
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(`/reset_password?token=${user.properties.email_password_reset_token}`);
function replaceTokens(s) {
return s
.replace(/%BOARDNAME%/g, Config.general.boardName)
.replace(/%USERNAME%/g, user.username)
.replace(/%TOKEN%/g, user.properties.email_password_reset_token)
.replace(/%RESET_URL%/g, resetUrl)
;
}
textTemplate = replaceTokens(textTemplate);
if(htmlTemplate) {
htmlTemplate = replaceTokens(htmlTemplate);
}
const message = {
to : `${user.properties.display_name||user.username} <${user.properties.email_address}>`,
// from will be filled in
subject : 'Forgot Password',
text : textTemplate,
html : htmlTemplate,
};
sendMail(message, (err, info) => {
// :TODO: Log me!
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 : '^\\/reset_password\\?token\\=[a-f0-9]+$', // Config.contentServers.web.forgotPasswordPageTemplate
handler : WebPasswordReset.routeResetPasswordGet,
},
// POST handler for performing the actual reset
{
method : 'POST',
path : '^\\/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('/reset_password');
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);
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.removeProperty('email_password_reset_token');
user.removeProperty('email_password_reset_token_ts');
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;

View File

@ -0,0 +1,7 @@
<b>%USERNAME%</b>:<br>
<br>
A password reset request has been generated for your account on <b>%BOARDNAME% BBS</b>:<br>
<br>
If you did not make this request, <u>you may safely ignore this email</u>. Otherwise, <a href="%RESET_URL%">please follow this link</a> to reset your password. If that does not work, you can copy the link below:<br>
<br>
%RESET_URL%<br>

View File

@ -0,0 +1,7 @@
%USERNAME%:
A password reset request has been generated for your account on %BOARDNAME% BBS:
If you did not make this request, you may safely ignore this email. Otherwise, please follow the link below to reset your password:
%RESET_URL%

View File

@ -27,7 +27,7 @@ exports.moduleInfo = {
name : 'Last Callers',
desc : 'Last callers to the system',
author : 'NuSkooler',
packageName : 'codes.l33t.enigma.lastcallers' // :TODO: concept idea for mods
packageName : 'codes.l33t.enigma.lastcallers'
};
const MciCodeIds = {

View File

@ -133,6 +133,24 @@
}
},
forgotPasswordPrompt: {
art: FORGOTPW
mci: {
ET1: {
argName: username
maxLength: @config:users.usernameMax
width: 32
focus: true
}
}
actionKeys: [
{
keys: [ "escape" ]
action: @systemMethod:prevMenu
}
]
}
///////////////////////////////////////////////////////////////////////
// File Base Related
///////////////////////////////////////////////////////////////////////
@ -167,6 +185,15 @@
]
}
fileBaseTagEntryPrompt: {
art: TAGFILE
mci: {
ET1: {
argName: tags
}
}
}
///////////////////////////////////////////////////////////////////////
// Standard / Required
//

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "enigma-bbs",
"version": "0.0.4-alpha",
"version": "0.0.5-alpha",
"description": "ENiGMA½ Bulletin Board System",
"author": "Bryan Ashby <bryan@l33t.codes>",
"license": "BSD-2-Clause",
@ -40,7 +40,8 @@
"sqlite3": "^3.1.1",
"ssh2": "^0.5.1",
"temptmp" : "^1.0.0",
"sanitize-filename" : "^1.6.1"
"sanitize-filename" : "^1.6.1",
"nodemailer" : "^3.1.3"
},
"devDependencies": {
},

View File

@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Password Reset — ENiGMA½ BBS</title>
<meta name="description" content="Reset your password">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>
window.onload = function() {
document.getElementById('password').onchange = validatePassword;
document.getElementById('confirm_password').onchange = validatePassword;
}
function validatePassword() {
var pw = document.getElementById('password');
var confirm = document.getElementById('confirm_password');
if(pw.value !== confirm.value) {
confirm.setCustomValidity('Passwords must match!');
} else {
confirm.setCustomValidity('');
}
}
</script>
</head>
<body>
<form action="%RESET_URL%" method="post">
<legend>Password Reset</legend>
<input type="password" placeholder="Password" id="password" name="password" required>
<input type="password" placeholder="Confirm Password" id="confirm_password" name="confirm_password" required>
<input type="hidden" value="%TOKEN%" name="token">
<button type="submit">Confirm</button>
</form>
</body>
</html>