Work on 2FA/OTP email system

* Web routes/handler/etc. mostly functional
* Can now enable -> follow link -> submit -> capture form
* Clean up code
This commit is contained in:
Bryan Ashby 2019-06-12 21:57:45 -06:00
parent fa3e3e5802
commit 3efea3de9a
No known key found for this signature in database
GPG Key ID: B49EB437951D2542
8 changed files with 273 additions and 85 deletions

View File

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

View File

@ -236,7 +236,8 @@ function getDefaultConfig() {
otp : { otp : {
registerEmailText : paths.join(__dirname, '../misc/otp_register_email.template.txt'), registerEmailText : paths.join(__dirname, '../misc/otp_register_email.template.txt'),
registerEmailHtml : paths.join(__dirname, '../misc/otp_register_email.template.html') registerEmailHtml : paths.join(__dirname, '../misc/otp_register_email.template.html'),
registerPageTemplate : paths.join(__dirname, '../www/otp_register.template.html'),
} }
} }
}, },

View File

@ -15,7 +15,7 @@ exports.sendMail = sendMail;
function sendMail(message, cb) { function sendMail(message, cb) {
const config = Config(); const config = Config();
if(!_.has(config, 'email.transport')) { if(!_.has(config, 'email.transport')) {
return cb(Errors.MissingConfig('Email "email::transport" configuration missing')); return cb(Errors.MissingConfig('Email "email.transport" configuration missing'));
} }
message.from = message.from || config.email.defaultFrom; message.from = message.from || config.email.defaultFrom;

View File

@ -10,20 +10,15 @@ const {
createQRCode, createQRCode,
} = require('./user_2fa_otp.js'); } = require('./user_2fa_otp.js');
const { Errors } = require('./enig_error.js'); const { Errors } = require('./enig_error.js');
const { sendMail } = require('./email.js');
const { getServer } = require('./listening_server.js'); const { getServer } = require('./listening_server.js');
const WebServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; const WebServerPackageName = require('./servers/content/web.js').moduleInfo.packageName;
const {
createToken,
WellKnownTokenTypes,
} = require('./user_temp_token.js');
const Config = require('./config.js').get; const Config = require('./config.js').get;
const WebRegister = require('./user_2fa_otp_web_register.js');
// deps // deps
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const iconv = require('iconv-lite'); const iconv = require('iconv-lite');
const fs = require('fs-extra');
exports.moduleInfo = { exports.moduleInfo = {
name : 'User 2FA/OTP Configuration', name : 'User 2FA/OTP Configuration',
@ -49,17 +44,6 @@ const DefaultMsg = {
noBackupCodes : 'No backup codes remaining or set.', noBackupCodes : 'No backup codes remaining or set.',
}; };
const DefaultEmailTextTemplate =
`%USERNAME%:
You have requested to enable 2-Factor Authentication via One-Time-Password
for your account on %BOARDNAME%.
* If this was not you, please ignore this email and change your password.
* Otherwise, please follow the link below:
%REGISTER_URL%
`;
exports.getModule = class User2FA_OTPConfigModule extends MenuModule { exports.getModule = class User2FA_OTPConfigModule extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
@ -82,8 +66,8 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule {
} }
initSequence() { initSequence() {
this.webServer = getServer(WebServerPackageName); const webServer = getServer(WebServerPackageName);
if(!this.webServer || !this.webServer.instance.isEnabled()) { if(!webServer || !webServer.instance.isEnabled()) {
this.client.log.warn('User 2FA/OTP configuration requires the web server to be enabled!'); this.client.log.warn('User 2FA/OTP configuration requires the web server to be enabled!');
return this.prevMenu( () => { /* dummy */ } ); return this.prevMenu( () => { /* dummy */ } );
} }
@ -215,66 +199,12 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule {
return cb(Errors.Invalid('Cannot convert selected index to valid OTP type')); return cb(Errors.Invalid('Cannot convert selected index to valid OTP type'));
} }
async.waterfall( this.removeUserOTPProperties(err => {
[ if(err) {
(callback) => {
return this.removeUserOTPProperties(callback);
},
(callback) => {
return createToken(this.client.user.userId, WellKnownTokenTypes.AuthFactor2OTPRegister, callback);
},
(token, callback) => {
const config = Config();
const txtTemplateFile = _.get(config, 'users.twoFactorAuth.otp.registerEmailText');
const htmlTemplateFile = _.get(config, 'users.twoFactorAuth.otp.registerEmailHtml');
fs.readFile(txtTemplateFile, 'utf8', (err, textTemplate) => {
textTemplate = textTemplate || DefaultEmailTextTemplate;
fs.readFile(htmlTemplateFile, 'utf8', (err, htmlTemplate) => {
htmlTemplate = htmlTemplate || null; // be explicit for waterfall
return callback(null, token, textTemplate, htmlTemplate);
});
});
},
(token, textTemplate, htmlTemplate, callback) => {
const registerUrl = this.webServer.instance.buildUrl(
`/enable_2fa_otp?token=&otpType=${otpTypeProp}&token=${token}`
);
const user = this.client.user;
const replaceTokens = (s) => {
return s
.replace(/%BOARDNAME%/g, Config().general.boardName)
.replace(/%USERNAME%/g, user.username)
.replace(/%TOKEN%/g, token)
.replace(/%REGISTER_URL%/g, registerUrl)
;
};
textTemplate = replaceTokens(textTemplate);
if(htmlTemplate) {
htmlTemplate = replaceTokens(htmlTemplate);
}
const message = {
to : `${user.getProperty(UserProps.RealName) || user.username} <${user.getProperty(UserProps.EmailAddress)}>`,
// from will be filled in
subject : '2-Factor Authentication Registration',
text : textTemplate,
html : htmlTemplate,
};
sendMail(message, (err, info) => {
// :TODO: Log info!
return callback(err);
});
}
],
err => {
return cb(err); return cb(err);
} }
); return WebRegister.sendRegisterEmail(this.client.user, otpTypeProp, cb);
});
} }
removeUserOTPProperties(cb) { removeUserOTPProperties(cb) {
@ -287,7 +217,7 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule {
} }
saveChangesDisable(cb) { saveChangesDisable(cb) {
this.removeUserOTPProperties( err => { this.removeUserOTPProperties(this.client.user, err => {
if(err) { if(err) {
return cb(err); return cb(err);
} }

View File

@ -0,0 +1,216 @@
/* 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 {
createToken,
getTokenInfo,
WellKnownTokenTypes,
} = require('./user_temp_token.js');
const { sendMail } = require('./email.js');
const UserProps = require('./user_property.js');
const Log = require('./logger.js').log;
// deps
const async = require('async');
const fs = require('fs-extra');
const _ = require('lodash');
const url = require('url');
const querystring = require('querystring');
function getWebServer() {
return getServer(webServerPackageName);
}
const DefaultEmailTextTemplate =
`%USERNAME%:
You have requested to enable 2-Factor Authentication via One-Time-Password
for your account on %BOARDNAME%.
* If this was not you, please ignore this email and change your password.
* Otherwise, please follow the link below:
%REGISTER_URL%
`;
module.exports = class User2FA_OTPWebRegister
{
static startup(cb) {
return User2FA_OTPWebRegister.registerRoutes(cb);
}
static sendRegisterEmail(user, otpType, cb) {
async.waterfall(
[
(callback) => {
return createToken(
user.userId,
WellKnownTokenTypes.AuthFactor2OTPRegister,
{ bits : 128 },
callback
);
},
(token, callback) => {
const config = Config();
const txtTemplateFile = _.get(config, 'users.twoFactorAuth.otp.registerEmailText');
const htmlTemplateFile = _.get(config, 'users.twoFactorAuth.otp.registerEmailHtml');
fs.readFile(txtTemplateFile, 'utf8', (err, textTemplate) => {
textTemplate = textTemplate || DefaultEmailTextTemplate;
fs.readFile(htmlTemplateFile, 'utf8', (err, htmlTemplate) => {
htmlTemplate = htmlTemplate || null; // be explicit for waterfall
return callback(null, token, textTemplate, htmlTemplate);
});
});
},
(token, textTemplate, htmlTemplate, callback) => {
const webServer = getWebServer();
const registerUrl = webServer.instance.buildUrl(
`/enable_2fa_otp?token=&otpType=${otpType}&token=${token}`
);
const replaceTokens = (s) => {
return s
.replace(/%BOARDNAME%/g, Config().general.boardName)
.replace(/%USERNAME%/g, user.username)
.replace(/%TOKEN%/g, token)
.replace(/%REGISTER_URL%/g, registerUrl)
;
};
textTemplate = replaceTokens(textTemplate);
if(htmlTemplate) {
htmlTemplate = replaceTokens(htmlTemplate);
}
const message = {
to : `${user.getProperty(UserProps.RealName) || user.username} <${user.getProperty(UserProps.EmailAddress)}>`,
// from will be filled in
subject : '2-Factor Authentication Registration',
text : textTemplate,
html : htmlTemplate,
};
sendMail(message, (err, info) => {
if(err) {
Log.warn({ error : err.message }, 'Failed sending 2FA/OTP register email');
} else {
Log.info( { info }, 'Successfully sent 2FA/OTP register email');
}
return callback(err);
});
}
],
err => {
return cb(err);
}
);
}
static fileNotFound(webServer, resp) {
return webServer.instance.fileNotFound(resp);
}
static accessDenied(webServer, resp) {
return webServer.instance.accessDenied(resp);
}
static routeRegisterGet(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;
const otpType = urlParts.query && urlParts.query.otpType;
if(!token || !otpType) {
return User2FA_OTPWebRegister.accessDenied(webServer, resp);
}
getTokenInfo(token, (err, tokenInfo) => {
if(err) {
// assume expired
return webServer.instance.respondWithError(resp, 410, 'Invalid or expired registration link.', 'Expired Link');
}
if(tokenInfo.tokenType !== 'auth_factor2_otp_register') {
return User2FA_OTPWebRegister.accessDenied(webServer, resp);
}
const qrImg = ''; // :TODO: fix me
const secret = '';
const backupCodes = '';
const postUrl = webServer.instance.buildUrl('/enable_2fa_otp');
const config = Config();
return webServer.instance.routeTemplateFilePage(
_.get(config, 'users.twoFactorAuth.otp.registerPageTemplate'),
(templateData, next) => {
const finalPage = templateData
.replace(/%BOARDNAME%/g, config.general.boardName)
.replace(/%USERNAME%/g, tokenInfo.user.username)
.replace(/%TOKEN%/g, token)
.replace(/%OTP_TYPE%/g, otpType)
.replace(/%POST_URL%/g, postUrl)
.replace(/%QR_IMG%/g, qrImg)
.replace(/%SECRET%/g, secret)
.replace(/%BACKUP_CODES%/g, backupCodes)
;
return next(null, finalPage);
},
resp
);
});
}
static routeRegisterPost(req, resp) {
const webServer = getWebServer(); // must be valid, we just got a req!
const badRequest = () => {
return webServer.instance.respondWithError(resp, 400, 'Bad Request.', 'Bad Request');
};
let bodyData = '';
req.on('data', data => {
bodyData += data;
});
req.on('end', () => {
const formData = querystring.parse(bodyData);
const config = Config();
if(!formData.token || !formData.otpType || !formData.otp) {
return badRequest();
}
});
return webServer.instance.respondWithError(resp, 410, 'Invalid or expired registration link.', 'Expired Link');
}
static registerRoutes(cb) {
const webServer = getWebServer();
if(!webServer || !webServer.instance.isEnabled()) {
return cb(null); // no webserver enabled
}
[
{
method : 'GET',
path : '^\\/enable_2fa_otp\\?token\\=[a-f0-9]+&otpType\\=[a-zA-Z0-9]+$',
handler : User2FA_OTPWebRegister.routeRegisterGet,
},
{
method : 'POST',
path : '^\\/enable_2fa_otp$',
handler : User2FA_OTPWebRegister.routeRegisterPost,
}
].forEach(r => {
webServer.instance.addRoute(r);
});
return cb(null);
}
};

View File

@ -25,17 +25,17 @@ exports.WellKnownTokenTypes = {
AuthFactor2OTPRegister : 'auth_factor2_otp_register', AuthFactor2OTPRegister : 'auth_factor2_otp_register',
}; };
function createToken(userId, tokenType, cb) { function createToken(userId, tokenType, options = { bits : 128 }, cb) {
async.waterfall( async.waterfall(
[ [
(callback) => { (callback) => {
return crypto.randomBytes(256, callback); return crypto.randomBytes(options.bits, callback);
}, },
(token, callback) => { (token, callback) => {
token = token.toString('hex'); token = token.toString('hex');
UserDb.run( UserDb.run(
`INSERT INTO user_temporary_token (user_id, token, token_type, timestamp) `INSERT OR REPLACE INTO user_temporary_token (user_id, token, token_type, timestamp)
VALUES (?, ?, ?, ?);`, VALUES (?, ?, ?, ?);`,
[ userId, token, tokenType, getISOTimestampString() ], [ userId, token, tokenType, getISOTimestampString() ],
err => { err => {

View File

@ -133,7 +133,7 @@ class WebPasswordReset {
if(err) { if(err) {
Log.warn( { error : err.message }, 'Failed sending password reset email' ); Log.warn( { error : err.message }, 'Failed sending password reset email' );
} else { } else {
Log.debug( { info : info }, 'Successfully sent password reset email'); Log.info( { info : info }, 'Successfully sent password reset email');
} }
return callback(err); return callback(err);

View File

@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Enable 2FA/OTP — ENiGMA½ BBS</title>
<meta name="description" content="Enable 2-Factor Authentication via One-Time-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="%POST_URL%" method="post">
<legend>Enable One-Time-Password</legend>
<input type="text" placeholder="One Time Password" id="otp" name="otp" required>
<input type="hidden" value="%TOKEN%" name="token">
<input type="hidden" value="%OTP_TYPE%" name="otpType">
<button type="submit">Confirm</button>
</form>
</body>
</html>