From 3efea3de9a5071c0fe8e4202a9b152d6b2df0e20 Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Wed, 12 Jun 2019 21:57:45 -0600 Subject: [PATCH] Work on 2FA/OTP email system * Web routes/handler/etc. mostly functional * Can now enable -> follow link -> submit -> capture form * Clean up code --- core/bbs.js | 4 + core/config.js | 5 +- core/email.js | 2 +- core/user_2fa_otp_config.js | 86 ++---------- core/user_2fa_otp_web_register.js | 216 ++++++++++++++++++++++++++++++ core/user_temp_token.js | 6 +- core/web_password_reset.js | 2 +- www/otp_register.template.html | 37 +++++ 8 files changed, 273 insertions(+), 85 deletions(-) create mode 100644 core/user_2fa_otp_web_register.js create mode 100644 www/otp_register.template.html diff --git a/core/bbs.js b/core/bbs.js index 4d371fb3..98ba1b7a 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -306,6 +306,10 @@ function initialize(cb) { const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset; 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) { const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule; EventSchedulerModule.loadAndStart( (err, modInst) => { diff --git a/core/config.js b/core/config.js index ee6ac90d..47868fa0 100644 --- a/core/config.js +++ b/core/config.js @@ -235,8 +235,9 @@ function getDefaultConfig() { method : 'googleAuth', otp : { - registerEmailText : paths.join(__dirname, '../misc/otp_register_email.template.txt'), - registerEmailHtml : paths.join(__dirname, '../misc/otp_register_email.template.html') + registerEmailText : paths.join(__dirname, '../misc/otp_register_email.template.txt'), + registerEmailHtml : paths.join(__dirname, '../misc/otp_register_email.template.html'), + registerPageTemplate : paths.join(__dirname, '../www/otp_register.template.html'), } } }, diff --git a/core/email.js b/core/email.js index 1de3b034..4a41106a 100644 --- a/core/email.js +++ b/core/email.js @@ -15,7 +15,7 @@ exports.sendMail = sendMail; function sendMail(message, cb) { const config = Config(); 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; diff --git a/core/user_2fa_otp_config.js b/core/user_2fa_otp_config.js index b95f2b75..09a72a20 100644 --- a/core/user_2fa_otp_config.js +++ b/core/user_2fa_otp_config.js @@ -10,20 +10,15 @@ const { createQRCode, } = require('./user_2fa_otp.js'); const { Errors } = require('./enig_error.js'); -const { sendMail } = require('./email.js'); const { getServer } = require('./listening_server.js'); const WebServerPackageName = require('./servers/content/web.js').moduleInfo.packageName; -const { - createToken, - WellKnownTokenTypes, -} = require('./user_temp_token.js'); const Config = require('./config.js').get; +const WebRegister = require('./user_2fa_otp_web_register.js'); // deps const async = require('async'); const _ = require('lodash'); const iconv = require('iconv-lite'); -const fs = require('fs-extra'); exports.moduleInfo = { name : 'User 2FA/OTP Configuration', @@ -49,17 +44,6 @@ const DefaultMsg = { 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 { constructor(options) { super(options); @@ -82,8 +66,8 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule { } initSequence() { - this.webServer = getServer(WebServerPackageName); - if(!this.webServer || !this.webServer.instance.isEnabled()) { + const webServer = getServer(WebServerPackageName); + if(!webServer || !webServer.instance.isEnabled()) { this.client.log.warn('User 2FA/OTP configuration requires the web server to be enabled!'); 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')); } - async.waterfall( - [ - (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 => { + this.removeUserOTPProperties(err => { + if(err) { return cb(err); } - ); + return WebRegister.sendRegisterEmail(this.client.user, otpTypeProp, cb); + }); } removeUserOTPProperties(cb) { @@ -287,7 +217,7 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule { } saveChangesDisable(cb) { - this.removeUserOTPProperties( err => { + this.removeUserOTPProperties(this.client.user, err => { if(err) { return cb(err); } diff --git a/core/user_2fa_otp_web_register.js b/core/user_2fa_otp_web_register.js new file mode 100644 index 00000000..840a5dd3 --- /dev/null +++ b/core/user_2fa_otp_web_register.js @@ -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); + } +}; diff --git a/core/user_temp_token.js b/core/user_temp_token.js index 76ca3438..89c060d6 100644 --- a/core/user_temp_token.js +++ b/core/user_temp_token.js @@ -25,17 +25,17 @@ exports.WellKnownTokenTypes = { AuthFactor2OTPRegister : 'auth_factor2_otp_register', }; -function createToken(userId, tokenType, cb) { +function createToken(userId, tokenType, options = { bits : 128 }, cb) { async.waterfall( [ (callback) => { - return crypto.randomBytes(256, callback); + return crypto.randomBytes(options.bits, callback); }, (token, callback) => { token = token.toString('hex'); 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 (?, ?, ?, ?);`, [ userId, token, tokenType, getISOTimestampString() ], err => { diff --git a/core/web_password_reset.js b/core/web_password_reset.js index 6fbb65b9..89c3fd33 100644 --- a/core/web_password_reset.js +++ b/core/web_password_reset.js @@ -133,7 +133,7 @@ class WebPasswordReset { if(err) { Log.warn( { error : err.message }, 'Failed sending password reset email' ); } else { - Log.debug( { info : info }, 'Successfully sent password reset email'); + Log.info( { info : info }, 'Successfully sent password reset email'); } return callback(err); diff --git a/www/otp_register.template.html b/www/otp_register.template.html new file mode 100644 index 00000000..a743928b --- /dev/null +++ b/www/otp_register.template.html @@ -0,0 +1,37 @@ + + + + + Enable 2FA/OTP — ENiGMA½ BBS + + + + + +
+ Enable One-Time-Password + + + + +
+ + \ No newline at end of file