diff --git a/core/menu_module.js b/core/menu_module.js index 06c0f6d7..526482da 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -16,6 +16,7 @@ const { getPredefinedMCIValue } = require('../core/predefined_mci.js'); const async = require('async'); const assert = require('assert'); const _ = require('lodash'); +const iconvDecode = require('iconv-lite').decode; exports.MenuModule = class MenuModule extends PluginModule { @@ -379,7 +380,7 @@ exports.MenuModule = class MenuModule extends PluginModule { ); } - displayAsset(name, options, cb) { + displayAsset(nameOrData, options, cb) { if(_.isFunction(options)) { cb = options; options = {}; @@ -389,10 +390,25 @@ exports.MenuModule = class MenuModule extends PluginModule { this.client.term.rawWrite(ansi.resetScreen()); } + options = Object.assign( { client : this.client, font : this.menuConfig.config.font }, options ); + + if(Buffer.isBuffer(nameOrData)) { + const data = iconvDecode(nameOrData, options.encoding || 'cp437'); + return theme.displayPreparedArt( + options, + { data }, + (err, artData) => { + if(cb) { + return cb(err, artData); + } + } + ); + } + return theme.displayThemedAsset( - name, + nameOrData, this.client, - Object.assign( { font : this.menuConfig.config.font }, options ), + options, (err, artData) => { if(cb) { return cb(err, artData); @@ -513,7 +529,7 @@ exports.MenuModule = class MenuModule extends PluginModule { } setViewText(formName, mciId, text, appendMultiLine) { - const view = this.viewControllers[formName].getView(mciId); + const view = this.getView(formName, mciId); if(!view) { return; } @@ -525,6 +541,11 @@ exports.MenuModule = class MenuModule extends PluginModule { } } + getView(formName, id) { + const form = this.viewControllers[formName]; + return form && form.getView(id); + } + updateCustomViewTextsWithFilter(formName, startId, fmtObj, options) { options = options || {}; diff --git a/core/oputil/oputil_user.js b/core/oputil/oputil_user.js index 92705bff..7b441875 100644 --- a/core/oputil/oputil_user.js +++ b/core/oputil/oputil_user.js @@ -372,12 +372,28 @@ function twoFactorAuthOTP(user) { prepareOTP, } = require('../../core/user_2fa_otp.js'); + let otpType = argv._[argv._.length - 1]; + + // shortcut for removal + if('disable' === otpType) { + const props = [ + UserProps.AuthFactor2OTP, + UserProps.AuthFactor2OTPSecret, + UserProps.AuthFactor2OTPBackupCodes, + ]; + return user.removeProperties(props, err => { + if(err) { + console.error(err.message); + } else { + console.info(`2FA OTP disabled for ${user.username}`); + } + }); + } + async.waterfall( [ function validate(callback) { // :TODO: Prompt for if not supplied - let otpType = argv._[argv._.length - 1]; - // allow aliases for OTP types otpType = { google : OTPTypes.GoogleAuthenticator, diff --git a/core/toggle_menu_view.js b/core/toggle_menu_view.js index aadfe9c3..c06297b2 100644 --- a/core/toggle_menu_view.js +++ b/core/toggle_menu_view.js @@ -35,11 +35,16 @@ util.inherits(ToggleMenuView, MenuView); ToggleMenuView.prototype.redraw = function() { ToggleMenuView.super_.prototype.redraw.call(this); + if(0 === this.items.length) { + return; + } + //this.cachePositions(); this.client.term.write(this.hasFocus ? this.getFocusSGR() : this.getSGR()); - assert(this.items.length === 2); + assert(this.items.length === 2, 'ToggleMenuView must contain exactly (2) items'); + for(var i = 0; i < 2; i++) { var item = this.items[i]; var text = strUtil.stylizeString( @@ -102,7 +107,7 @@ ToggleMenuView.prototype.onKeyPress = function(ch, key) { if(key) { if(this.isKeyMapped('right', key.name) || this.isKeyMapped('down', key.name)) { this.focusNext(); - } else if(this.isKeyMapped('left', key.name) || this.isKeyMapped('up', key.nam4e)) { + } else if(this.isKeyMapped('left', key.name) || this.isKeyMapped('up', key.name)) { this.focusPrevious(); } } diff --git a/core/user_2fa_otp.js b/core/user_2fa_otp.js index 2f51f105..0b53ad8b 100644 --- a/core/user_2fa_otp.js +++ b/core/user_2fa_otp.js @@ -17,10 +17,11 @@ const Config = require('./config.js').get; // deps const _ = require('lodash'); const crypto = require('crypto'); -const async = require('async'); const qrGen = require('qrcode-generator'); exports.prepareOTP = prepareOTP; +exports.createQRCode = createQRCode; +exports.otpFromType = otpFromType; exports.loginFactor2_OTP = loginFactor2_OTP; const OTPTypes = exports.OTPTypes = { diff --git a/core/user_2fa_otp_config.js b/core/user_2fa_otp_config.js new file mode 100644 index 00000000..a4bbb3df --- /dev/null +++ b/core/user_2fa_otp_config.js @@ -0,0 +1,171 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const { MenuModule } = require('./menu_module.js'); +const UserProps = require('./user_property.js'); +const { + OTPTypes, + otpFromType, + createQRCode, +} = require('./user_2fa_otp.js'); + +// deps +const async = require('async'); +const _ = require('lodash'); +const iconv = require('iconv-lite'); + +exports.moduleInfo = { + name : 'User 2FA/OTP Configuration', + desc : 'Module for user 2FA/OTP configuration', + author : 'NuSkooler', +}; + +const FormIds = { + menu : 0, +}; + +const MciViewIds = { + enableToggle : 1, + typeSelection : 2, + submission : 3, + infoText : 4, + + customRangeStart : 10, // 10+ = customs +}; + +exports.getModule = class User2FA_OTPConfigModule extends MenuModule { + constructor(options) { + super(options); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + + this.menuMethods = { + showQRCode : (formData, extraArgs, cb) => { + return this.showQRCode(cb); + } + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (callback) => { + return this.prepViewController('menu', FormIds.menu, mciData.menu, callback); + }, + (callback) => { + const requiredCodes = [ + MciViewIds.enableToggle, + MciViewIds.typeSelection, + MciViewIds.submission, + ]; + return this.validateMCIByViewIds('menu', requiredCodes, callback); + }, + (callback) => { + const enableToggleView = this.getView('menu', MciViewIds.enableToggle); + let initialIndex = this.isOTPEnabledForUser() ? 1 : 0; + enableToggleView.setFocusItemIndex(initialIndex); + this.enableToggleUpdate(initialIndex); + + enableToggleView.on('index update', idx => { + return this.enableToggleUpdate(idx); + }); + + const typeSelectionView = this.getView('menu', MciViewIds.typeSelection); + initialIndex = this.typeSelectionIndexFromUserOTPType(); + typeSelectionView.setFocusItemIndex(initialIndex); + + typeSelectionView.on('index update', idx => { + return this.typeSelectionUpdate(idx); + }); + + this.viewControllers.menu.on('return', view => { + if(view === enableToggleView) { + return this.enableToggleUpdate(enableToggleView.focusedItemIndex); + } else if (view === typeSelectionView) { + return this.typeSelectionUpdate(typeSelectionView.focusedItemIndex); + } + }); + + return callback(null); + } + ], + err => { + return cb(err); + } + ); + }); + } + + showQRCode(cb) { + const otp = otpFromType(this.client.user.getProperty(UserProps.AuthFactor2OTP)); + let qrCodeAscii = ''; + if(!otp) { + qrCodeAscii = '2FA/OTP is not currently enabled for this account'; + } + + const qrOptions = { + username : this.client.user.username, + qrType : 'ascii', + }; + qrCodeAscii = createQRCode( + otp, + qrOptions, + this.client.user.getProperty(UserProps.AuthFactor2OTPSecret) + ).replace(/\n/g, '\r\n'); + + const modOpts = { + extraArgs : { + artData : iconv.encode(`${qrCodeAscii}\r\n`, 'cp437'), + } + }; + this.gotoMenu( + this.menuConfig.config.mainMenuUser2FAOTP_ShowQR || 'mainMenuUser2FAOTP_ShowQR', + modOpts, + cb + ); + } + + isOTPEnabledForUser() { + return this.typeSelectionIndexFromUserOTPType(-1) != -1; + } + + getInfoText(key) { + return _.get(this.config, [ 'infoText', key ], ''); + } + + enableToggleUpdate(idx) { + const key = { + 0 : '2faDisabled', + 1 : '2faEnabled', + }[idx]; + this.updateCustomViewTextsWithFilter('menu', MciViewIds.customRangeStart, { infoText : this.getInfoText(key) } ); + } + + typeSelectionIndexFromUserOTPType(defaultIndex = 0) { + const type = this.client.user.getProperty(UserProps.AuthFactor2OTP); + return { + [ OTPTypes.RFC6238_TOTP ] : 0, + [ OTPTypes.RFC4266_HOTP ] : 1, + [ OTPTypes.GoogleAuthenticator ] : 2, + }[type] || defaultIndex; + } + + otpTypeFromTypeSelectionIndex(idx) { + return { + 0 : OTPTypes.RFC6238_TOTP, + 1 : OTPTypes.RFC4266_HOTP, + 2 : OTPTypes.GoogleAuthenticator, + }[idx]; + } + + typeSelectionUpdate(idx) { + const key = '2faType_' + this.otpTypeFromTypeSelectionIndex(idx); + this.updateCustomViewTextsWithFilter('menu', MciViewIds.customRangeStart, { infoText : this.getInfoText(key) } ); + } +}; + diff --git a/core/user_property.js b/core/user_property.js index 00f640fb..88ac11b1 100644 --- a/core/user_property.js +++ b/core/user_property.js @@ -60,7 +60,7 @@ module.exports = { SSHPubKey : 'ssh_public_key', // OpenSSH format (ssh-keygen, etc.) AuthFactor1Types : 'auth_factor1_types', // List of User.AuthFactor1Types value(s) - AuthFactor2OTP : 'auth_factor2_otp', // If present, OTP type for 2FA + 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 }; diff --git a/core/view.js b/core/view.js index 7d3c5693..b675b508 100644 --- a/core/view.js +++ b/core/view.js @@ -178,6 +178,12 @@ View.prototype.setSpecialKeyMapOverride = function(specialKeyMapOverride) { View.prototype.setPropertyValue = function(propName, value) { switch(propName) { + case 'acceptsFocus' : + if (_.isBoolean(value)) { + this.acceptsFocus = value; + } + break; + case 'height' : this.setHeight(value); break; case 'width' : this.setWidth(value); break; case 'focus' : this.setFocus(value); break;