diff --git a/core/config.js b/core/config.js index 0bb82838..ee6ac90d 100644 --- a/core/config.js +++ b/core/config.js @@ -233,6 +233,11 @@ function getDefaultConfig() { twoFactorAuth : { method : 'googleAuth', + + otp : { + registerEmailText : paths.join(__dirname, '../misc/otp_register_email.template.txt'), + registerEmailHtml : paths.join(__dirname, '../misc/otp_register_email.template.html') + } } }, diff --git a/core/user_2fa_otp_config.js b/core/user_2fa_otp_config.js index 851e197e..b95f2b75 100644 --- a/core/user_2fa_otp_config.js +++ b/core/user_2fa_otp_config.js @@ -13,12 +13,17 @@ 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; // deps const async = require('async'); const _ = require('lodash'); const iconv = require('iconv-lite'); -const crypto = require('crypto'); +const fs = require('fs-extra'); exports.moduleInfo = { name : 'User 2FA/OTP Configuration', @@ -44,6 +49,17 @@ 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); @@ -205,25 +221,54 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule { return this.removeUserOTPProperties(callback); }, (callback) => { - return crypto.randomBytes(256, callback); + return createToken(this.client.user.userId, WellKnownTokenTypes.AuthFactor2OTPRegister, callback); }, (token, callback) => { - // :TODO: consider temporary tokens table - this has become semi-common - // token | timestamp | token_type | - // abc | ISO | '2fa_otp_register' - token = token.toString('hex'); - this.client.user.persistProperty(UserProps.AuthFactor2OTPEnableToken, token, err => { - return callback(err, token); + 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, callback) => { - const resetUrl = this.webServer.instance.buildUrl( + (token, textTemplate, htmlTemplate, callback) => { + const registerUrl = this.webServer.instance.buildUrl( `/enable_2fa_otp?token=&otpType=${otpTypeProp}&token=${token}` ); - // clear any existing (e.g. same as disable) -> send activation email + const user = this.client.user; - return callback(null); + 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 => { diff --git a/core/user_property.js b/core/user_property.js index 8d7ca91c..88ac11b1 100644 --- a/core/user_property.js +++ b/core/user_property.js @@ -63,6 +63,5 @@ module.exports = { AuthFactor2OTP : 'auth_factor2_otp', // If present, OTP type for 2FA. See OTPTypes AuthFactor2OTPSecret : 'auth_factor2_otp_secret', // Secret used in conjunction with OTP 2FA AuthFactor2OTPBackupCodes : 'auth_factor2_otp_backup', // JSON array of backup codes - AuthFactor2OTPEnableToken : 'auth_factor2_otp_enable_token', }; diff --git a/core/user_temp_token.js b/core/user_temp_token.js new file mode 100644 index 00000000..76ca3438 --- /dev/null +++ b/core/user_temp_token.js @@ -0,0 +1,140 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const UserDb = require('./database.js').dbs.user; +const { + getISOTimestampString +} = require('./database.js'); +const { Errors } = require('./enig_error.js'); +const User = require('./user.js'); +const Log = require('./logger.js').log; + +// deps +const crypto = require('crypto'); +const async = require('async'); +const moment = require('moment'); + +exports.createToken = createToken; +exports.deleteToken = deleteToken; +exports.deleteTokenByUserAndType = deleteTokenByUserAndType; +exports.getTokenInfo = getTokenInfo; +exports.temporaryTokenMaintenanceTask = temporaryTokenMaintenanceTask; + +exports.WellKnownTokenTypes = { + AuthFactor2OTPRegister : 'auth_factor2_otp_register', +}; + +function createToken(userId, tokenType, cb) { + async.waterfall( + [ + (callback) => { + return crypto.randomBytes(256, callback); + }, + (token, callback) => { + token = token.toString('hex'); + + UserDb.run( + `INSERT INTO user_temporary_token (user_id, token, token_type, timestamp) + VALUES (?, ?, ?, ?);`, + [ userId, token, tokenType, getISOTimestampString() ], + err => { + return callback(err, token); + } + ); + } + ], + (err, token) => { + return cb(err, token); + } + ); +} + +function deleteToken(token, cb) { + UserDb.run( + `DELETE FROM user_temporary_token + WHERE token = ?;`, + [ token ], + err => { + return cb(err); + } + ); +} + +function deleteTokenByUserAndType(userId, tokenType, cb) { + UserDb.run( + `DELETE FROM user_temporary_token + WHERE user_id = ? AND token_type = ?;`, + [ userId, tokenType ], + err => { + return cb(err); + } + ); +} + +function getTokenInfo(token, cb) { + async.waterfall( + [ + (callback) => { + UserDb.get( + `SELECT user_id, token_type, timestamp + FROM user_temporary_token + WHERE token = ?;`, + [ token ], + (err, row) => { + if(err) { + return callback(err); + } + + if(!row) { + return callback(Errors.DoesNotExist('No entry found for token')); + } + + const info = { + userId : row.user_id, + tokenType : row.token_type, + timestamp : moment(row.timestamp), + }; + return callback(null, info); + } + ); + }, + (info, callback) => { + User.getUser(info.userId, (err, user) => { + info.user = user; + return callback(err, info); + }); + } + ], + (err, info) => { + return cb(err, info); + } + ); +} + +function temporaryTokenMaintenanceTask(args, cb) { + const tokenType = args[0]; + + if(!tokenType) { + return Log.error('Cannot run temporary token maintenance task with out specifying "tokenType" as argument 0'); + } + + const expTime = args[1] || '24 hours'; + + UserDb.run( + `DELETE FROM user_temporary_token + WHERE token IN ( + SELECT token + FROM user_temporary_token + WHERE token_type = ? + AND DATETIME("now") >= DATETIME(timestamp, "+${expTime}") + );`, + [ tokenType ], + err => { + if(err) { + Log.warn( { error : err.message, tokenType }, 'Failed deleting user temporary token'); + } + return cb(err); + } + ); +} diff --git a/core/web_password_reset.js b/core/web_password_reset.js index 90c5f57c..6fbb65b9 100644 --- a/core/web_password_reset.js +++ b/core/web_password_reset.js @@ -22,7 +22,7 @@ const _ = require('lodash'); const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT = `%USERNAME%: -a password reset has been requested for your account on %BOARDNAME%. +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%