From 6070bc94e75aa2ebe7476f48c3eed36c00cbabad Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Thu, 9 May 2019 19:56:04 -0600 Subject: [PATCH] Good progress on QR support --- core/oputil/oputil_user.js | 62 +++++++++++++++++++++- core/user_2fa_otp.js | 104 ++++++++++++++++++++++++------------- core/user_login.js | 3 ++ package.json | 3 +- yarn.lock | 5 ++ 5 files changed, 139 insertions(+), 38 deletions(-) diff --git a/core/oputil/oputil_user.js b/core/oputil/oputil_user.js index 33327d1d..a2725a71 100644 --- a/core/oputil/oputil_user.js +++ b/core/oputil/oputil_user.js @@ -16,6 +16,7 @@ const UserProps = require('../user_property.js'); const async = require('async'); const _ = require('lodash'); const moment = require('moment'); +const fs = require('fs-extra'); exports.handleUserCommand = handleUserCommand; @@ -343,6 +344,62 @@ Affiliations : ${propOrNA(UserProps.Affiliations)} `); } +function twoFactorAuth(user) { + if(argv._.length < 4) { + return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); + } + + const { + OTPTypes, + prepareOTP, + } = require('../../core/user_2fa_otp.js'); + + async.waterfall( + [ + function validate(callback) { + // :TODO: Prompt for if not supplied + let otpType = argv._[argv._.length - 1]; + otpType = _.find(OTPTypes, t => { + return t.toLowerCase() === otpType; + }); + if(!otpType) { + return callback(Errors.Invalid('Invalid OTP type')); + } + return callback(null, otpType); + }, + function prepare(otpType, callback) { + const otpOpts = { + username : user.username, + qrType : argv['qr-type'] || 'ascii', + }; + prepareOTP(otpType, otpOpts, (err, otpInfo) => { + return callback(err, otpInfo); + }); + }, + function storeOrDisplayQR(otpInfo, callback) { + if(!argv.out) { + return callback(null, otpInfo); + } + + if('-' === argv.out) { + console.info(otpInfo.qr); + return callback(null, otpInfo); + } + + fs.writeFile(argv.out, otpInfo.qr, 'utf8', err => { + return callback(err, otpInfo); + }); + } + ], + (err) => { + if(err) { + console.error(err.message); + } else { + } + } + ); +} + function handleUserCommand() { function errUsage() { return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); @@ -356,7 +413,8 @@ function handleUserCommand() { const usernameIdx = [ 'pw', 'pass', 'passwd', 'password', 'group', - 'mv', 'rename' + 'mv', 'rename', + '2fa', ].includes(action) ? argv._.length - 2 : argv._.length - 1; const userName = argv._[usernameIdx]; @@ -391,6 +449,8 @@ function handleUserCommand() { group : modUserGroups, info : showUserInfo, + + '2fa' : twoFactorAuth, }[action] || errUsage)(user, action); }); } \ No newline at end of file diff --git a/core/user_2fa_otp.js b/core/user_2fa_otp.js index b0df5009..eadb09eb 100644 --- a/core/user_2fa_otp.js +++ b/core/user_2fa_otp.js @@ -12,14 +12,16 @@ const { recordLogin, transformLoginError, } = require('./user_login.js'); +const Config = require('./config.js').get; // deps const _ = require('lodash'); const crypto = require('crypto'); const async = require('async'); +const qrGen = require('qrcode-generator'); -exports.loginFactor2_OTP = loginFactor2_OTP; -exports.generateNewBackupCodes = generateNewBackupCodes; +exports.prepareOTP = prepareOTP; +exports.loginFactor2_OTP = loginFactor2_OTP; const OTPTypes = exports.OTPTypes = { RFC6238_TOTP : 'rfc6238_TOTP', // Time-Based, SHA-512 @@ -28,23 +30,27 @@ const OTPTypes = exports.OTPTypes = { }; function otpFromType(otpType) { - return { - [ OTPTypes.RFC6238_TOTP ] : () => { - const totp = require('otplib/totp'); - totp.options = { crypto, algorithm : 'sha256' }; - return totp; - }, - [ OTPTypes.RFC4266_HOTP ] : () => { - const hotp = require('otplib/hotp'); - hotp.options = { crypto, algorithm : 'sha256' }; - return hotp; - }, - [ OTPTypes.GoogleAuthenticator ] : () => { - const googleAuth = require('otplib/authenticator'); - googleAuth.options = { crypto }; - return googleAuth; - }, - }[otpType](); + try { + return { + [ OTPTypes.RFC6238_TOTP ] : () => { + const totp = require('otplib/totp'); + totp.options = { crypto, algorithm : 'sha256' }; + return totp; + }, + [ OTPTypes.RFC4266_HOTP ] : () => { + const hotp = require('otplib/hotp'); + hotp.options = { crypto, algorithm : 'sha256' }; + return hotp; + }, + [ OTPTypes.GoogleAuthenticator ] : () => { + const googleAuth = require('otplib/authenticator'); + googleAuth.options = { crypto }; + return googleAuth; + }, + }[otpType](); + } catch(e) { + // nothing + } } function generateOTPBackupCode() { @@ -79,13 +85,9 @@ function backupCodePBKDF2(secret, salt, cb) { return crypto.pbkdf2(secret, salt, 1000, 128, 'sha1', cb); } -function generateNewBackupCodes(user, cb) { - // - // Backup codes are not stored in plain text, but rather - // an array of objects: [{salt, code}, ...] - // - const plainCodes = [...Array(6)].map(() => generateOTPBackupCode()); - async.map(plainCodes, (code, nextCode) => { +function generateNewBackupCodes(cb) { + const plainTextCodes = [...Array(6)].map(() => generateOTPBackupCode()); + async.map(plainTextCodes, (code, nextCode) => { crypto.randomBytes(16, (err, salt) => { if(err) { return nextCode(err); @@ -101,14 +103,7 @@ function generateNewBackupCodes(user, cb) { }); }, (err, codes) => { - if(err) { - return cb(err); - } - - codes = JSON.stringify(codes); - user.persistProperty(UserProps.AuthFactor2OTPBackupCodes, codes, err => { - return cb(err, plainCodes); - }); + return cb(err, codes, plainTextCodes); }); } @@ -149,13 +144,51 @@ function validateAndConsumeBackupCode(user, token, cb) { } } +function createQRCode(otp, options, secret) { + const uri = otp.keyuri(options.username || 'user', Config().general.boardName, secret); + const qrCode = qrGen(0, 'L'); + qrCode.addData(uri); + qrCode.make(); + + options.qrType = options.qrType || 'ascii'; + return { + ascii : qrCode.createASCII, + data : qrCode.createDataURL, + img : qrCode.createImgTag, + svg : qrCode.createSvgTag, + }[options.qrType](); +} + +function prepareOTP(otpType, options, cb) { + if(!_.isFunction(cb)) { + cb = options; + options = {}; + } + + const otp = otpFromType(otpType); + if(!otp) { + return cb(Errors.Invalid(`Unknown OTP type: ${otpType}`)); + } + + const secret = OTPTypes.GoogleAuthenticator === otpType ? + otp.generateSecret() : + crypto.randomBytes(64).toString('base64').substr(0, 32); + + generateNewBackupCodes((err, codes, plainTextCodes) => { + const qr = createQRCode(otp, options, secret); + return cb(err, { secret, codes, plainTextCodes, qr } ); + }); +} + function loginFactor2_OTP(client, token, cb) { if(client.user.authFactor < User.AuthFactors.Factor1) { return cb(Errors.AccessDenied('OTP requires prior authentication factor 1')); } const otpType = client.user.getProperty(UserProps.AuthFactor2OTP); - if(!_.values(OTPTypes).includes(otpType)) { + const otp = otpFromType(otpType); + + if(!otp) { return cb(Errors.Invalid(`Unknown OTP type: ${otpType}`)); } @@ -164,7 +197,6 @@ function loginFactor2_OTP(client, token, cb) { return cb(Errors.Invalid('Missing OTP secret')); } - const otp = otpFromType(otpType); const valid = otp.verify( { token, secret } ); const allowLogin = () => { diff --git a/core/user_login.js b/core/user_login.js index ed2ba6a5..bc23e3b3 100644 --- a/core/user_login.js +++ b/core/user_login.js @@ -20,6 +20,7 @@ const User = require('./user.js'); // deps const async = require('async'); const _ = require('lodash'); +const assert = require('assert'); exports.userLogin = userLogin; exports.recordLogin = recordLogin; @@ -110,6 +111,8 @@ function userLogin(client, username, password, options, cb) { } function recordLogin(client, cb) { + assert(client.user.authenticated); // don't get in situations where this isn't true + const user = client.user; async.parallel( [ diff --git a/package.json b/package.json index 4a700f1d..bbc8a94f 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,8 @@ "ws": "^6.1.3", "xxhash": "^0.2.4", "yazl": "^2.5.1", - "otplib": "^10.0.1" + "otplib": "^10.0.1", + "qrcode-generator": "1.4.3" }, "devDependencies": {}, "engines": { diff --git a/yarn.lock b/yarn.lock index 09bf3e98..0b081218 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1570,6 +1570,11 @@ punycode@^1.4.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= +qrcode-generator@1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/qrcode-generator/-/qrcode-generator-1.4.3.tgz#4876e8f280e65b6c94615f4c19c484f6b964b199" + integrity sha512-++rVRvMRq5BlHfmAafl8a4ppUntzUxCCUTT2t0siUgqKwdnqRzY8IH6f6WSX5dZUhD2Ul5/MIKuTJddflwrGzw== + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"