diff --git a/core/abracadabra.js b/core/abracadabra.js index 9ac4dec7..83aa376b 100644 --- a/core/abracadabra.js +++ b/core/abracadabra.js @@ -79,7 +79,7 @@ exports.getModule = class AbracadabraModule extends MenuModule { /* :TODO: - * disconnecting wile door is open leaves dosemu + * disconnecting while door is open leaves dosemu * http://bbslink.net/sysop.php support * Font support ala all other menus... or does this just work? */ diff --git a/core/database.js b/core/database.js index 91f56a04..b3778833 100644 --- a/core/database.js +++ b/core/database.js @@ -203,6 +203,22 @@ const DB_INIT_TABLE = { );` ); + // + // Table for temporary tokens, generally used for e.g. 'outside' + // access such as email links. + // Examples: PW reset, enabling of 2FA/OTP, etc. + // + dbs.user.run( + `CREATE TABLE IF NOT EXISTS user_temporary_token ( + user_id INTEGER NOT NULL, + token VARCHAR NOT NULL, + timestamp DATETIME NOT NULL, + purpose VARCHAR NOT NULL, + UNIQUE(user_id, token), + FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE + );` + ); + return cb(null); }, diff --git a/core/user_2fa_otp_config.js b/core/user_2fa_otp_config.js index a47e0e6d..851e197e 100644 --- a/core/user_2fa_otp_config.js +++ b/core/user_2fa_otp_config.js @@ -9,11 +9,16 @@ const { otpFromType, 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; // deps const async = require('async'); const _ = require('lodash'); const iconv = require('iconv-lite'); +const crypto = require('crypto'); exports.moduleInfo = { name : 'User 2FA/OTP Configuration', @@ -26,10 +31,10 @@ const FormIds = { }; const MciViewIds = { - enableToggle : 1, - typeSelection : 2, - submission : 3, - infoText : 4, + enableToggle : 1, + otpType : 2, + submit : 3, + infoText : 4, customRangeStart : 10, // 10+ = customs }; @@ -53,10 +58,22 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule { }, showBackupCodes : (formData, extraArgs, cb) => { return this.showBackupCodes(cb); + }, + saveChanges : (formData, extraArgs, cb) => { + return this.saveChanges(formData, cb); } }; } + initSequence() { + this.webServer = getServer(WebServerPackageName); + if(!this.webServer || !this.webServer.instance.isEnabled()) { + this.client.log.warn('User 2FA/OTP configuration requires the web server to be enabled!'); + return this.prevMenu( () => { /* dummy */ } ); + } + return super.initSequence(); + } + mciReady(mciData, cb) { super.mciReady(mciData, err => { if(err) { @@ -71,8 +88,8 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule { (callback) => { const requiredCodes = [ MciViewIds.enableToggle, - MciViewIds.typeSelection, - MciViewIds.submission, + MciViewIds.otpType, + MciViewIds.submit, ]; return this.validateMCIByViewIds('menu', requiredCodes, callback); }, @@ -86,19 +103,19 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule { return this.enableToggleUpdate(idx); }); - const typeSelectionView = this.getView('menu', MciViewIds.typeSelection); - initialIndex = this.typeSelectionIndexFromUserOTPType(); - typeSelectionView.setFocusItemIndex(initialIndex); + const otpTypeView = this.getView('menu', MciViewIds.otpType); + initialIndex = this.otpTypeIndexFromUserOTPType(); + otpTypeView.setFocusItemIndex(initialIndex); - typeSelectionView.on('index update', idx => { - return this.typeSelectionUpdate(idx); + otpTypeView.on('index update', idx => { + return this.otpTypeUpdate(idx); }); this.viewControllers.menu.on('return', view => { if(view === enableToggleView) { return this.enableToggleUpdate(enableToggleView.focusedItemIndex); - } else if (view === typeSelectionView) { - return this.typeSelectionUpdate(typeSelectionView.focusedItemIndex); + } else if (view === otpTypeView) { + return this.otpTypeUpdate(otpTypeView.focusedItemIndex); } }); @@ -169,8 +186,74 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule { return this.displayDetails(info, cb); } + saveChanges(formData, cb) { + const enabled = 1 === _.get(formData, 'value.enableToggle', 0); + return enabled ? this.saveChangesEnable(formData, cb) : this.saveChangesDisable(cb); + } + + saveChangesEnable(formData, cb) { + const otpTypeProp = this.otpTypeFromOTPTypeIndex(_.get(formData, 'value.otpType')); + + // sanity check + if(!otpFromType(otpTypeProp)) { + return cb(Errors.Invalid('Cannot convert selected index to valid OTP type')); + } + + async.waterfall( + [ + (callback) => { + return this.removeUserOTPProperties(callback); + }, + (callback) => { + return crypto.randomBytes(256, 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); + }); + }, + (token, callback) => { + const resetUrl = this.webServer.instance.buildUrl( + `/enable_2fa_otp?token=&otpType=${otpTypeProp}&token=${token}` + ); + + // clear any existing (e.g. same as disable) -> send activation email + + return callback(null); + } + ], + err => { + return cb(err); + } + ); + } + + removeUserOTPProperties(cb) { + const props = [ + UserProps.AuthFactor2OTP, + UserProps.AuthFactor2OTPSecret, + UserProps.AuthFactor2OTPBackupCodes, + ]; + return this.client.user.removeProperties(props, cb); + } + + saveChangesDisable(cb) { + this.removeUserOTPProperties( err => { + if(err) { + return cb(err); + } + + // :TODO: show "saved+disabled" art/message -> prevMenu + return cb(null); + }); + } + isOTPEnabledForUser() { - return this.typeSelectionIndexFromUserOTPType(-1) != -1; + return this.otpTypeIndexFromUserOTPType(-1) != -1; } getInfoText(key) { @@ -185,7 +268,7 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule { this.updateCustomViewTextsWithFilter('menu', MciViewIds.customRangeStart, { infoText : this.getInfoText(key) } ); } - typeSelectionIndexFromUserOTPType(defaultIndex = 0) { + otpTypeIndexFromUserOTPType(defaultIndex = 0) { const type = this.client.user.getProperty(UserProps.AuthFactor2OTP); return { [ OTPTypes.RFC6238_TOTP ] : 0, @@ -194,7 +277,7 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule { }[type] || defaultIndex; } - otpTypeFromTypeSelectionIndex(idx) { + otpTypeFromOTPTypeIndex(idx) { return { 0 : OTPTypes.RFC6238_TOTP, 1 : OTPTypes.RFC4266_HOTP, @@ -202,8 +285,8 @@ exports.getModule = class User2FA_OTPConfigModule extends MenuModule { }[idx]; } - typeSelectionUpdate(idx) { - const key = this.otpTypeFromTypeSelectionIndex(idx); + otpTypeUpdate(idx) { + const key = this.otpTypeFromOTPTypeIndex(idx); this.updateCustomViewTextsWithFilter('menu', MciViewIds.customRangeStart, { infoText : this.getInfoText(key) } ); } }; diff --git a/core/user_property.js b/core/user_property.js index 88ac11b1..8d7ca91c 100644 --- a/core/user_property.js +++ b/core/user_property.js @@ -63,5 +63,6 @@ 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', };