diff --git a/src/cli/cloud.js b/src/cli/cloud.js index 711264138..ba35fe9a1 100644 --- a/src/cli/cloud.js +++ b/src/cli/cloud.js @@ -108,8 +108,7 @@ export default ({ commandProcessor, root }) => { examples: { '$0 $command': 'prompt for credentials and log in', '$0 $command --username user@example.com --password test': 'log in with credentials provided on the command line', - '$0 $command --token ': 'log in with an access token provided on the command line', - '$0 $command --token --username user@example.com': 'log in with an access token provided on the command line and set your username' + '$0 $command --token ': 'log in with an access token provided on the command line' }, options: { u: { @@ -123,15 +122,18 @@ export default ({ commandProcessor, root }) => { nargs: 1 }, t: { - description: '', + description: 'an existing Particle access token to use', alias: 'token', nargs: 1 + }, + otp: { + description: 'the login code if two-step authentication is enabled', + nargs: 1 } }, handler: (args) => { const CloudCommands = require('../cmd/cloud'); - const { username, password, token } = args; - return new CloudCommands().login(username, password, token); + return new CloudCommands().login(args); } }); diff --git a/src/cmd/cloud.js b/src/cmd/cloud.js index b33703052..0c68e5d38 100644 --- a/src/cmd/cloud.js +++ b/src/cmd/cloud.js @@ -336,7 +336,7 @@ class CloudCommand { }); } - login(username, password, token) { + login({ username, password, token, otp } = {}) { const shouldRetry = !((username && password) || token && !this.tries); return Promise.resolve() @@ -356,11 +356,24 @@ class CloudCommand { this.newSpin('Sending login details...').start(); this._usernameProvided = username; - if (token){ - return api.getUser(token).then(() => ({ token, username, password })); + if (token) { + return api.getUser(token).then((response) => { + return { + token, + username: response.username + }; + }); } return api.login(settings.clientId, username, password) - .then(token => ({ token, username, password })); + .catch((error) => { + if (error.error === 'mfa_required') { + this.stopSpin(); + + this.tries = 0; + return this.enterOtp({ otp, mfaToken: error.mfa_token, shouldRetry }); + } + throw error; + }).then(token => ({ token, username })); }) .then(credentials => { const { token, username } = credentials; @@ -382,16 +395,46 @@ class CloudCommand { .catch(error => { this.stopSpin(); console.log(alert, `There was an error logging you in! ${shouldRetry ? "Let's try again." : ''}`); - console.error(alert, error); + console.error(alert, error.message || error.error_description); this.tries = (this.tries || 0) + 1; if (shouldRetry && this.tries < 3){ - return this.login(this._usernameProvided); + return this.login({ username: this._usernameProvided }); } throw new VError("It seems we're having trouble with logging in."); }); } + enterOtp({ otp, mfaToken, shouldRetry }) { + return Promise.resolve().then(() => { + if (!this.tries) { + console.log('Use your authenticator app on your mobile device to get a login code.'); + console.log('Lost access to your phone? Visit https://login.particle.io/account-info'); + } + + if (otp) { + return otp; + } + return prompts.getOtp(); + }).then(_otp => { + otp = _otp; + this.newSpin('Sending login code...').start(); + + const api = new ApiClient(); + return api.sendOtp(settings.clientId, mfaToken, otp); + }).catch(error => { + this.stopSpin(); + console.log(alert, `This login code didn't work. ${shouldRetry ? "Let's try again." : ''}`); + console.error(alert, error.message || error.error_description); + this.tries = (this.tries || 0) + 1; + + if (shouldRetry && this.tries < 3){ + return this.enterOtp({ mfaToken, shouldRetry }); + } + throw new VError('Recover your account at https://login.particle.io/account-info'); + }); + } + doLogout(keep, password) { const api = new ApiClient(); diff --git a/src/cmd/serial.js b/src/cmd/serial.js index 3b4209f32..bdba8c4f2 100644 --- a/src/cmd/serial.js +++ b/src/cmd/serial.js @@ -16,8 +16,7 @@ try { } const wifiScan = require('node-wifiscanner2').scan; const specs = require('../lib/deviceSpecs'); -const ApiClient = require('../lib/ApiClient2'); -const OldApiClient = require('../lib/ApiClient'); +const ApiClient = require('../lib/ApiClient'); const settings = require('../../settings'); const DescribeParser = require('binary-version-reader').HalDescribeParser; const YModem = require('../lib/ymodem'); @@ -619,7 +618,11 @@ class SerialCommand { function getClaim() { self.newSpin('Obtaining magical secure claim code from the cloud...').start(); - api.getClaimCode(undefined, afterClaim); + api.getClaimCode().then((response) => { + afterClaim(null, response); + }, (error) => { + afterClaim(error); + }); } function revived() { @@ -627,7 +630,11 @@ class SerialCommand { self.newSpin("Attempting to verify the Photon's connection to the cloud...").start(); setTimeout(() => { - api.listDevices(checkDevices); + api.listDevices({ silent: true }).then((body) => { + checkDevices(null, body); + }, (error) => { + checkDevices(error); + }); }, 6000); } @@ -646,7 +653,7 @@ class SerialCommand { self.exit(); } - // self.__deviceID -> _deviceID + // self.deviceID -> _deviceID const onlinePhoton = _.find(dat, (device) => { return (device.id.toUpperCase() === _deviceID.toUpperCase()) && device.connected === true; }); @@ -674,7 +681,11 @@ class SerialCommand { function recheck(ans) { if (ans.recheck === 'recheck') { - api.listDevices(checkDevices); + api.listDevices({ silent: true }).then((body) => { + checkDevices(null, body); + }, (error) => { + checkDevices(error); + }); } else { self._promptForListeningMode(); self.setup(device); @@ -683,8 +694,6 @@ class SerialCommand { } function namePhoton(deviceId) { - const __oldapi = new OldApiClient(); - prompt([ { type: 'input', @@ -695,7 +704,7 @@ class SerialCommand { // todo - retrieve existing name of the device? const deviceName = ans.deviceName; if (deviceName) { - __oldapi.renameDevice(deviceId, deviceName).then(() => { + api.renameDevice(deviceId, deviceName).then(() => { console.log(); console.log(arrow, 'Your Photon has been given the name', chalk.bold.cyan(deviceName)); console.log(arrow, "Congratulations! You've just won the internet!"); diff --git a/src/cmd/setup.js b/src/cmd/setup.js index d183c24b8..cc23d2fe2 100644 --- a/src/cmd/setup.js +++ b/src/cmd/setup.js @@ -1,10 +1,9 @@ const chalk = require('chalk'); const prompt = require('inquirer').prompt; -const ApiClient2 = require('../../dist/lib/ApiClient2'); -const settings = require('../../settings.js'); -const ApiClient = require('../../dist/lib/ApiClient.js'); -const utilities = require('../../dist/lib/utilities.js'); +const settings = require('../../settings'); +const ApiClient = require('../lib/ApiClient'); +const utilities = require('../lib/utilities'); const when = require('when'); const sequence = require('when/sequence'); @@ -35,9 +34,7 @@ class SetupCommand { constructor() { spinnerMixin(this); - this.__wasLoggedIn; - this.__api = new ApiClient2(); - this.__oldapi = new ApiClient(); + this.api = new ApiClient(); } command(name, options = { params: {} }) { @@ -58,7 +55,7 @@ class SetupCommand { loginCheck(); function loginCheck() { - self.__wasLoggedIn = !!settings.username; + self.wasLoggedIn = !!settings.username; if (settings.access_token) { return promptSwitch(); @@ -90,8 +87,7 @@ class SetupCommand { // user wants to logout if (!ans.switch) { return self.command('cloud').logout(true).then(() => { - self.__api.clearToken(); - self.__oldapi.clearToken(); + self.api.clearToken(); accountStatus(false); }); } else { @@ -106,7 +102,7 @@ class SetupCommand { if (!alreadyLoggedIn) { // New user or a fresh environment! - if (!self.__wasLoggedIn) { + if (!self.wasLoggedIn) { self.prompt([ { type: 'list', @@ -147,7 +143,7 @@ class SetupCommand { ); } const self = this; - const signupUsername = this.__signupUsername || undefined; + const signupUsername = this.signupUsername || undefined; console.log(arrow, "Let's create your new account!"); self.prompt([{ @@ -194,7 +190,7 @@ class SetupCommand { // try to remember username to save them some frustration if (ans.username) { - self.__signupUsername = ans.username; + self.signupUsername = ans.username; } console.log( arrow, @@ -203,30 +199,19 @@ class SetupCommand { return self.signup(cb, ++tries); } - self.__api.createUser(ans.username, ans.password, (signupErr) => { - if (signupErr) { - console.error(signupErr); - console.error(alert, "Oops, that didn't seem to work. Let's try that again"); - return self.signup(cb, ++tries); - } - + self.api.createUser(ans.username, ans.password).then(() => { // Login the new user automatically - self.__api.login(settings.clientId, ans.username, ans.password, (loginErr, body) => { - // if just the login fails, reset to the login part of the setup flow - if (loginErr) { - console.error(loginErr); - console.error(alert, 'We had a problem logging you in :('); - return self.login(cb); - } - - self.__oldapi.updateToken(body.access_token); - - settings.override(null, 'username', ans.username); - console.log(arrow, strings.signupSuccess); - cb(null); - }); + return self.api.login(settings.clientId, ans.username, ans.password); + }).then((token) => { + settings.override(null, 'access_token', token); + settings.override(null, 'username', ans.username); + console.log(arrow, strings.signupSuccess); + cb(null); + }).catch((signupErr) => { + console.error(signupErr); + console.error(alert, "Oops, that didn't seem to work. Let's try that again"); + return self.signup(cb, ++tries); }); - } } @@ -236,14 +221,12 @@ class SetupCommand { console.log(arrow, "Let's get you logged in!"); this.command('cloud').login().then((accessToken) => { - self.__api.updateToken(accessToken); - self.__oldapi.updateToken(accessToken); + self.api.updateToken(accessToken); cb(); }).catch(() => {}); } findDevice() { - const self = this; const serial = this.command('serial'); const wireless = this.command('wireless'); @@ -459,12 +442,12 @@ class SetupCommand { () => { self.newSpin('Claiming the core to your account').start(); return utilities.retryDeferred(() => { - return self.__oldapi.claimDevice(deviceId); + return self.api.claimDevice(deviceId); }, 3, promptForCyan); }, () => { self.stopSpin(); - return self.__oldapi.signalDevice(deviceId, true); + return self.api.signalDevice(deviceId, true); }, () => { const rainbow = when.defer(); @@ -491,10 +474,10 @@ class SetupCommand { deviceName = ans.coreName; sequence([ () => { - return self.__oldapi.signalDevice(deviceId, false); + return self.api.signalDevice(deviceId, false); }, () => { - return self.__oldapi.renameDevice(deviceId, deviceName); + return self.api.renameDevice(deviceId, deviceName); } ]).then(naming.resolve, naming.reject); }); diff --git a/src/cmd/token.js b/src/cmd/token.js index bb6014726..ff8adb95d 100644 --- a/src/cmd/token.js +++ b/src/cmd/token.js @@ -121,6 +121,7 @@ class AccessTokenCommands { return this.getCredentials(); }).then(creds => { return api.createAccessToken(clientName, creds.username, creds.password); + // TODO: add support for MFA here }).then(result => { const nowUnix = Date.now(); const expiresUnix = nowUnix + (result.expires_in * 1000); diff --git a/src/cmd/wireless.js b/src/cmd/wireless.js index 158373fc6..68b609e13 100644 --- a/src/cmd/wireless.js +++ b/src/cmd/wireless.js @@ -1,8 +1,7 @@ const _ = require('lodash'); const util = require('util'); const WiFiManager = require('../lib/WiFiManager'); -const OldApiClient = require('../lib/ApiClient.js'); -const APIClient = require('../lib/ApiClient2'); +const ApiClient = require('../lib/ApiClient.js'); const settings = require('../../settings.js'); const inquirer = require('inquirer'); const prompt = inquirer.prompt; @@ -51,23 +50,21 @@ class WirelessCommand { this.options = options; this.deviceFilterPattern = settings.wirelessSetupFilter; - this.__sap = new SAP(); - this.__manual = false; - this.__completed = 0; - this.__apiClient = new APIClient(); - this.__oldapi = new OldApiClient(); + this.sap = new SAP(); + this.manual = false; + this.api = new ApiClient(); this.prompt = prompt; } list(macAddress, manual) { if (manual) { - this.__manual = true; + this.manual = true; } // if we get passed a MAC address from setup if (macAddress && macAddress.length === 17) { - this.__macAddressFilter = macAddress; + this.macAddressFilter = macAddress; } else { - this.__macAddressFilter = null; + this.macAddressFilter = null; } console.log(); @@ -87,7 +84,7 @@ class WirelessCommand { console.log(); this.newSpin('%s ' + chalk.bold.white('Scanning Wi-Fi for nearby Photons in setup mode...')).start(); - scan(this.__networks.bind(this)); + scan(this.networks.bind(this)); } } @@ -100,7 +97,7 @@ class WirelessCommand { * ssid: the SSID of the AP * @private */ - __networks(err, dat) { + networks(err, dat) { const self = this; let detectedDevices = []; @@ -124,9 +121,9 @@ class WirelessCommand { } detectedDevices = dat; - if (this.__macAddressFilter) { + if (this.macAddressFilter) { const macDevices = detectedDevices.filter((ap) => { - return ap.mac && (ap.mac.toLowerCase() === self.__macAddressFilter); + return ap.mac && (ap.mac.toLowerCase() === self.macAddressFilter); }); if (macDevices && macDevices.length === 1) { @@ -192,7 +189,7 @@ class WirelessCommand { protip("Your Photon will appear in your computer's list of Wi-Fi networks with a name like,", chalk.cyan('Photon-XXXX')); protip('Where', chalk.cyan('XXXX'), 'is a string of random letters and/or numbers', chalk.cyan('unique'), 'to that specific Photon.'); - self.__manual = true; + self.manual = true; return self.setup(null, manualDone); } @@ -206,7 +203,7 @@ class WirelessCommand { if (ans.setup) { - self.__batch = false; + self.batch = false; // Select any/all Photons to setup return self.prompt([{ @@ -275,7 +272,7 @@ class WirelessCommand { const foundPhotons = filter(dat, args || settings.wirelessSetupFilter); if (foundPhotons.length > 0) { - self.__networks(null, foundPhotons); + self.networks(null, foundPhotons); } else { setTimeout(wildPhotons, 5000); @@ -285,15 +282,13 @@ class WirelessCommand { } setup(photon, cb) { - - const api = this.__apiClient; const mgr = new WiFiManager(); const self = this; - this.__ssid = photon; + this.ssid = photon; console.log(); - if (!photon && !self.__manual) { + if (!photon && !self.manual) { console.log(alert, 'No Photons selected for setup!'); return self.exit(); @@ -336,7 +331,11 @@ class WirelessCommand { function getClaim() { self.newSpin('Obtaining magical secure claim code from the cloud...').start(); - api.getClaimCode(undefined, afterClaim); + self.api.getClaimCode().then((response) => { + afterClaim(null, response); + }, (error) => { + afterClaim(error); + }); } function afterClaim(err, dat) { @@ -359,23 +358,23 @@ class WirelessCommand { console.log(arrow, 'Obtained magical secure claim code.'); console.log(); - self.__claimCode = dat.claim_code; + self.claimCode = dat.claim_code; // todo - prompt for manual connection before getting the claim code since this exits the setup process - if (!self.__manual && !mgr.supported.connect) { + if (!self.manual && !mgr.supported.connect) { console.log(); console.log(alert, 'I am unable to automatically connect to Wi-Fi networks', chalk.magenta('(-___-)')); console.log(); return self.manualAsk((ans) => { if (ans.manual) { - self.__manual = true; + self.manual = true; return manualConnect(); } console.log(arrow, 'Goodbye!'); }); } - if (!self.__manual) { + if (!self.manual) { self.newSpin('Attempting to connect to ' + photon + '...').start(); mgr.connect({ ssid: photon }, connected); } else { @@ -394,7 +393,7 @@ class WirelessCommand { } function manualReady() { - self.__configure(null, manualConfigure); + self.configure(null, manualConfigure); } function manualConfigure(err, dat) { @@ -417,12 +416,12 @@ class WirelessCommand { 'Hey! We successfully connected to', chalk.bold.cyan(opts.ssid) ); - self.__configure(opts.ssid); + self.configure(opts.ssid); } } /* eslint-disable max-statements */ - __configure(ssid, cb) { + configure(ssid, cb) { console.log(); @@ -431,8 +430,7 @@ class WirelessCommand { console.log(); const self = this; - const sap = this.__sap; - const api = self.__apiClient; + const sap = this.sap; const mgr = new WiFiManager(); const list = []; let password; @@ -684,9 +682,9 @@ class WirelessCommand { name: 'network', message: 'Please select the network to which your Photon should connect:', choices: networks - }]).then(__networkChoice); + }]).then(networkChoice); - function __networkChoice(ans) { + function networkChoice(ans) { if (ans.network === strings.rescanLabel) { console.log(); @@ -705,7 +703,7 @@ class WirelessCommand { return networkChoices({ network: network }); } - if (list[network].sec & self.__sap.securityValue('enterprise')) { + if (list[network].sec & self.sap.securityValue('enterprise')) { enterpriseChoices({ network: network, security: list[network].sec }); } else { self.prompt([{ @@ -714,11 +712,11 @@ class WirelessCommand { name: 'password', message: 'Please enter your network password:' - }]).then(__passwordChoice); + }]).then(passwordChoice); } } - function __passwordChoice(ans) { + function passwordChoice(ans) { networkChoices({ network: network, password: ans.password }); } } @@ -730,8 +728,8 @@ class WirelessCommand { security = ans.security || list[network].sec; let visibleSecurity; - if (self.__sap.securityLookup(security)) { - visibleSecurity = self.__sap.securityLookup(security).toUpperCase().replace('_', ' '); + if (self.sap.securityLookup(security)) { + visibleSecurity = self.sap.securityLookup(security).toUpperCase().replace('_', ' '); } else { visibleSecurity = security.toUpperCase().replace('_', ' '); } @@ -778,11 +776,11 @@ class WirelessCommand { if (!ans.continue) { console.log(arrow, "Let's try again..."); console.log(); - return self.__configure(ssid, cb); + return self.configure(ssid, cb); } - self.__network = network; + self.network = network; if (!isEnterprise) { - self.__password = password; + self.password = password; } info(); @@ -808,7 +806,7 @@ class WirelessCommand { } if (dat && dat.id) { - self.__deviceID = dat.id; + self.deviceID = dat.id; console.log(arrow, 'Setting up device id', chalk.bold.cyan(dat.id.toLowerCase())); } clearTimeout(retry); @@ -824,7 +822,7 @@ class WirelessCommand { clearTimeout(retry); console.log(arrow, 'Setting the magical cloud claim code...'); - sap.setClaimCode(self.__claimCode, configure); + sap.setClaimCode(self.claimCode, configure); } function configure(err) { @@ -875,7 +873,7 @@ class WirelessCommand { self.stopSpin(); //console.log(arrow, chalk.bold.white('Configuration complete! You\'ve just won the internet!')); - if (!self.__manual && !isEnterprise) { + if (!self.manual && !isEnterprise) { reconnect(false); } else { manualReconnectPrompt(); @@ -897,7 +895,7 @@ class WirelessCommand { function reconnect(manual) { if (!manual) { self.newSpin('Reconnecting your computer to your Wi-Fi network...').start(); - mgr.connect({ ssid: self.__network, password: self.__password }, revived); + mgr.connect({ ssid: self.network, password: self.password }, revived); } else { revived(); } @@ -913,9 +911,11 @@ class WirelessCommand { self.newSpin("Attempting to verify the Photon's connection to the cloud...").start(); setTimeout(() => { - - api.listDevices(checkDevices); - + self.api.listDevices({ silent: true }).then((body) => { + checkDevices(null, body); + }, (error) => { + checkDevices(error); + }); }, 2000); } @@ -939,7 +939,7 @@ class WirelessCommand { } const onlinePhoton = _.find(dat, (device) => { - return (device.id.toUpperCase() === self.__deviceID.toUpperCase()) && device.connected === true; + return (device.id.toUpperCase() === self.deviceID.toUpperCase()) && device.connected === true; }); if (onlinePhoton) { @@ -966,9 +966,13 @@ class WirelessCommand { function recheck(ans) { if (ans.recheck === 'recheck') { - api.listDevices(checkDevices); + self.api.listDevices({ silent: true }).then((body) => { + checkDevices(null, body); + }, (error) => { + checkDevices(error); + }); } else { - self.setup(self.__ssid); + self.setup(self.ssid); } } } @@ -984,7 +988,7 @@ class WirelessCommand { // todo - retrieve existing name of the device? const deviceName = ans.deviceName; if (deviceName) { - self.__oldapi.renameDevice(deviceId, deviceName).then(() => { + self.api.renameDevice(deviceId, deviceName).then(() => { console.log(); console.log(arrow, 'Your Photon has been given the name', chalk.bold.cyan(deviceName)); console.log(arrow, "Congratulations! You've just won the internet!"); diff --git a/src/lib/ApiClient.js b/src/lib/ApiClient.js index c57f10322..f7172a780 100644 --- a/src/lib/ApiClient.js +++ b/src/lib/ApiClient.js @@ -106,8 +106,7 @@ class ApiClient { this._access_token = token; } - // doesn't appear to be used (renamed) - _createUser (user, pass) { + createUser(user, pass) { let dfd = when.defer(); //todo; if !user, make random? @@ -121,8 +120,6 @@ class ApiClient { return when.reject('Username must be an email address.'); } - - console.log('creating user: ', user); let that = this; this.request({ @@ -138,13 +135,10 @@ class ApiClient { return dfd.reject(error); } if (body && body.ok) { - console.log('user creation succeeded!'); that._user = user; that._pass = pass; } else if (body && !body.ok && body.errors) { - console.log('User creation ran into an issue: ', body.errors); - } else { - console.log('createUser got ', body + ''); + return dfd.reject(body.errors); } dfd.resolve(body); @@ -191,9 +185,6 @@ class ApiClient { that._access_token = resp.access_token; return when.resolve(that._access_token); - }, - err => { - return when.reject('Login Failed: ' + err); }); } @@ -223,7 +214,7 @@ class ApiClient { return reject(error); } if (body.error) { - reject(body.error_description); + reject(body); } else { resolve(body); } @@ -231,6 +222,33 @@ class ApiClient { }); } + sendOtp (clientId, mfaToken, otp) { + return when.promise((resolve, reject) => { + this.request({ + uri: '/oauth/token', + method: 'POST', + form: { + mfa_token: mfaToken, + otp, + grant_type: 'urn:custom:mfa-otp', + client_id: clientId, + client_secret: 'client_secret_here' + }, + json: true + }, (error, response, body) => { + if (error) { + return reject(error); + } + if (body.error) { + reject(body); + } else { + this._access_token = body.access_token; + resolve(this._access_token); + } + }); + }); + } + //DELETE /v1/access_tokens/{ACCESS_TOKEN} /** * Removes the given access token, outputting any errors to the console. @@ -303,9 +321,11 @@ class ApiClient { //GET /v1/devices - listDevices () { + listDevices ({ silent = false } = {}) { let spinner = new Spinner('Retrieving devices...'); - spinner.start(); + if (!silent) { + spinner.start(); + } let that = this; let prom = when.promise((resolve, reject) => { @@ -321,7 +341,9 @@ class ApiClient { return reject('Invalid token'); } if (body.error) { - console.error('listDevices got error: ', body.error); + if (!silent) { + console.error('listDevices got error: ', body.error); + } reject(body.error); } else { that._devices = body; @@ -1018,6 +1040,31 @@ class ApiClient { return dfd.promise; } + getClaimCode() { + let dfd = when.defer(); + this.request({ + uri: '/v1/device_claims', + method: 'POST', + qs: { + access_token: this._access_token, + }, + json: true + }, (error, response, body) => { + if (error) { + return dfd.reject(error); + } + if (this.hasBadToken(body)) { + return dfd.reject('Invalid token'); + } + if (!body || !body.claim_code) { + return dfd.reject(new Error('Unable to obtain claim code')); + } + dfd.resolve(body); + }); + + return dfd.promise; + } + hasBadToken(body) { if (body && body.error && body.error.indexOf && (body.error.indexOf('invalid_token') >= 0)) { @@ -1064,6 +1111,8 @@ class ApiClient { reason = response.info; } else if (response.error) { reason = response.error; + } else if (response.error_description) { + reason = response.error_description; } return new Error(reason); } diff --git a/src/lib/ApiClient2.js b/src/lib/ApiClient2.js deleted file mode 100644 index 5ffdf0ac1..000000000 --- a/src/lib/ApiClient2.js +++ /dev/null @@ -1,176 +0,0 @@ - - -const request = require('request'); -const utilities = require('./utilities'); -const settings = require('../../settings'); - -/* - * This variant of APIClient uses callbacks rather than promises, to satisfy the needs of the setup command, which - * also uses callbacks. Login alters global state, setting the access token on the settings instance, but otheriwse - * the commands do not introduce side effects (e.g. no console output.) - */ - -class APIClient2 { - constructor(baseUrl, token) { - this.__token = token || settings.access_token; - this.request = request.defaults({ - baseUrl: baseUrl || settings.apiUrl, - proxy: settings.proxyUrl || process.env.HTTPS_PROXY || process.env.https_proxy - }); - } - - updateToken(token) { - this.__token = token; - } - - clearToken() { - this.__token = null; - } - - /** - * Used in setup command. - */ - login(clientId, user, pass, cb) { - this.createAccessToken(clientId, user, pass, (err, dat) => { - if (err) { - return cb(err); - } - - cb(null, dat); - }); - } - -// used in setup process to create a new account - createUser(user, pass, cb) { - if (!user || (user === '') - || (!utilities.contains(user, '@')) - || (!utilities.contains(user, '.'))) { - return cb('Username must be an email address.'); - } - - this.request({ - uri: '/v1/users', - method: 'POST', - form: { - username: user, - password: pass - }, - json: true - }, (error, response, body) => { - if (error) { - return cb(error); - } - - if (body && !body.ok && body.errors) { - return cb(body.errors); - } - - return cb(null, body); - }); - } - - /** - * Creates an access token, updates the global settings with the new token, and the token in this instance. - * Used only by login above. - * todo - updating the token should probably be moved to login() - */ - createAccessToken(clientId, user, pass, cb) { - - let self = this; - this.request({ - - uri: '/oauth/token', - method: 'POST', - form: { - - username: user, - password: pass, - grant_type: 'password', - client_id: clientId, - client_secret: 'client_secret_here' - - }, - json: true - - }, (err, res, body) => { - - if (err || body.error) { - - cb(new Error(err || body.error)); - - } else { - // todo factor this out creating and updating should be separate - // update the token - if (body.access_token) { - - settings.override( - settings.profile, - 'access_token', - body.access_token - ); - - self.__token = body.access_token; - } - - // console.log(arrow, 'DEBUG'); - // console.log(body); - // console.log(); - - cb(null, body); - } - }); - } - - getClaimCode(data, cb) { - - let self = this; - - this.request({ - - uri: '/v1/device_claims', - method: 'POST', - auth: { - 'bearer': self.__token - }, - json: true, - body: data - }, (err, res, body) => { - - if (err) { - return cb(err); - } - if ((!body) || !body.claim_code) { - - return cb(new Error('Unable to obtain claim code')); - } - cb(null, body); - }); - } - - listDevices(cb) { - - let self = this; - - this.request({ - - uri: '/v1/devices', - method: 'GET', - auth: { - 'bearer': self.__token - }, - json: true - }, (err, res, body) => { - - if (err) { - return cb(err); - } - if (!body) { - - return cb(new Error('Unable to list devices')); - } - cb(null, body); - }); - } -} - -module.exports = APIClient2; diff --git a/src/lib/prompts.js b/src/lib/prompts.js index 36bcf5dbb..0b068fd07 100644 --- a/src/lib/prompts.js +++ b/src/lib/prompts.js @@ -174,6 +174,15 @@ const prompts = { }, confirmPassword() { return prompts.passPromptDfd('confirm password '); + }, + getOtp() { + return inquirer.prompt([ + { + type: 'input', + name: 'otp', + message: 'Please enter a login code' + } + ]).then((ans) => ans.otp); } }; diff --git a/test/cmd/cloud.spec.js b/test/cmd/cloud.spec.js index 78b564ed7..c18671bbe 100644 --- a/test/cmd/cloud.spec.js +++ b/test/cmd/cloud.spec.js @@ -1,15 +1,18 @@ const proxyquire = require('proxyquire'); -const expect = require('chai').expect; +const { expect, sinon } = require('../test-setup'); const sandbox = require('sinon').createSandbox(); +const _ = require('lodash'); const stubs = { api: { login: () => {}, + sendOtp: () => {}, getUser: () => {} }, utils: {}, prompts: { - getCredentials: () => {} + getCredentials: () => {}, + getOtp: () => {} }, settings: { clientId: 'CLITESTS', @@ -30,14 +33,16 @@ const CloudCommands = proxyquire('../../src/cmd/cloud', { describe('Cloud Commands', () => { - let fakeToken, fakeTokenPromise, fakeCredentials, fakeUser, fakeUserPromise; + let fakeToken, fakeCredentials, fakeUser; + let fakeMfaToken, fakeOtp, fakeOtpError; beforeEach(() => { fakeToken = 'FAKE-ACCESS-TOKEN'; - fakeTokenPromise = Promise.resolve(fakeToken); fakeCredentials = { username: 'test@example.com', password: 'fake-pw' }; - fakeUser = {}; - fakeUserPromise = Promise.resolve(fakeUser); + fakeUser = { username: 'test@example.com' }; + fakeMfaToken = 'abc1234'; + fakeOtp = '123456'; + fakeOtpError = { error: 'mfa_required', mfa_token: fakeMfaToken }; }); afterEach(() => { @@ -45,26 +50,11 @@ describe('Cloud Commands', () => { }); it('accepts token arg', withConsoleStubs(() => { - const { cloud, api, settings } = stubForLogin(new CloudCommands(), stubs); - api.getUser.returns(fakeUserPromise); - - return cloud.login(null, null, fakeToken) - .then(t => { - expect(t).to.equal(fakeToken); - expect(api.login).to.have.property('callCount', 0); - expect(api.getUser).to.have.property('callCount', 1); - expect(api.getUser.firstCall.args).to.eql([fakeToken]); - expect(settings.override).to.have.property('callCount', 1); - expect(settings.override.firstCall.args).to.eql([null, 'access_token', fakeToken]); - }); - })); - - it('accepts token and username args', withConsoleStubs(() => { const { cloud, api, settings } = stubForLogin(new CloudCommands(), stubs); const { username } = fakeCredentials; - api.getUser.returns(fakeUserPromise); + api.getUser.resolves(fakeUser); - return cloud.login(username, null, fakeToken) + return cloud.login({ token: fakeToken }) .then(t => { expect(t).to.equal(fakeToken); expect(api.login).to.have.property('callCount', 0); @@ -79,9 +69,9 @@ describe('Cloud Commands', () => { it('accepts username and password args', withConsoleStubs(() => { const { cloud, api, settings } = stubForLogin(new CloudCommands(), stubs); const { username, password } = fakeCredentials; - api.login.returns(fakeTokenPromise); + api.login.resolves(fakeToken); - return cloud.login(username, password) + return cloud.login({ username, password }) .then(t => { expect(t).to.equal(fakeToken); expect(api.login).to.have.property('callCount', 1); @@ -99,7 +89,7 @@ describe('Cloud Commands', () => { const { cloud, api, prompts, settings } = stubForLogin(new CloudCommands(), stubs); const { username, password } = fakeCredentials; prompts.getCredentials.returns(fakeCredentials); - api.login.returns(fakeTokenPromise); + api.login.resolves(fakeToken); return cloud.login() .then(t => { @@ -143,7 +133,7 @@ describe('Cloud Commands', () => { const { cloud, api, settings } = stubForLogin(new CloudCommands(), stubs); api.login.throws(); - return cloud.login('username', 'password') + return cloud.login({ username: 'username', password: 'password' }) .then(() => { throw new Error('expected promise to be rejected'); }) @@ -159,15 +149,101 @@ describe('Cloud Commands', () => { }); })); + describe('with mfa', () => { + it('accepts username, password and otp args', withConsoleStubs(() => { + const { cloud, api, settings } = stubForLogin(new CloudCommands(), stubs); + const { username, password } = fakeCredentials; + api.login.rejects(fakeOtpError); + api.sendOtp.resolves(fakeToken); + + return cloud.login({ username, password, otp: fakeOtp }) + .then(t => { + expect(t).to.equal(fakeToken); + expect(api.login).to.have.property('callCount', 1); + expect(api.login.firstCall).to.have.property('args').lengthOf(3); + expect(api.login.firstCall.args[0]).to.equal(stubs.settings.clientId); + expect(api.login.firstCall.args[1]).to.equal(username); + expect(api.login.firstCall.args[2]).to.equal(password); + expect(api.sendOtp).to.have.property('callCount', 1); + expect(api.sendOtp.firstCall).to.have.property('args').lengthOf(3); + expect(api.sendOtp.firstCall.args[0]).to.equal(stubs.settings.clientId); + expect(api.sendOtp.firstCall.args[1]).to.equal(fakeMfaToken); + expect(api.sendOtp.firstCall.args[2]).to.equal(fakeOtp); + expect(settings.override).to.have.property('callCount', 2); + expect(settings.override.firstCall.args).to.eql([null, 'access_token', fakeToken]); + expect(settings.override.secondCall.args).to.eql([null, 'username', username]); + }); + })); + + it('prompts for username, password and otp when they are not provided', withConsoleStubs(() => { + const { cloud, api, prompts, settings } = stubForLogin(new CloudCommands(), stubs); + const { username, password } = fakeCredentials; + prompts.getCredentials.returns(fakeCredentials); + prompts.getOtp.returns(fakeOtp); + api.login.rejects(fakeOtpError); + api.sendOtp.resolves(fakeToken); + + return cloud.login() + .then(t => { + expect(t).to.equal(fakeToken); + expect(prompts.getCredentials).to.have.property('callCount', 1); + expect(prompts.getOtp).to.have.property('callCount', 1); + expect(cloud.newSpin).to.have.property('callCount', 2); + expect(cloud.stopSpin).to.have.property('callCount', 2); + expect(api.login).to.have.property('callCount', 1); + expect(api.login.firstCall).to.have.property('args').lengthOf(3); + expect(api.login.firstCall.args[0]).to.equal(stubs.settings.clientId); + expect(api.login.firstCall.args[1]).to.equal(username); + expect(api.login.firstCall.args[2]).to.equal(password); + expect(api.sendOtp).to.have.property('callCount', 1); + expect(api.sendOtp.firstCall).to.have.property('args').lengthOf(3); + expect(api.sendOtp.firstCall.args[0]).to.equal(stubs.settings.clientId); + expect(api.sendOtp.firstCall.args[1]).to.equal(fakeMfaToken); + expect(api.sendOtp.firstCall.args[2]).to.equal(fakeOtp); + expect(settings.override).to.have.property('callCount', 2); + expect(settings.override.firstCall.args).to.eql([null, 'access_token', fakeToken]); + expect(settings.override.secondCall.args).to.eql([null, 'username', username]); + }); + })); + + it('does not retry after 3 attemps', withConsoleStubs(() => { + const { cloud, api, prompts, settings } = stubForLogin(new CloudCommands(), stubs); + prompts.getCredentials.returns(fakeCredentials); + prompts.getOtp.returns(fakeOtp); + api.login.rejects(fakeOtpError); + api.sendOtp.throws(); + + return cloud.login() + .then(() => { + throw new Error('expected promise to be rejected'); + }) + .catch(error => { + const stdoutArgs = process.stdout.write.args; + const lastLog = stdoutArgs[stdoutArgs.length - 1]; + + expect(cloud.login).to.have.property('callCount', 1); + expect(cloud.enterOtp).to.have.property('callCount', 3); + expect(settings.override).to.have.property('callCount', 0); + expect(lastLog[0]).to.match(/There was an error logging you in! Let's try again.\n$/); + expect(process.stderr.write).to.have.property('callCount', 4); + expect(error).to.have.property('message', 'It seems we\'re having trouble with logging in.'); + }); + })); + }); + + function stubForLogin(cloud, stubs){ const { api, prompts, settings } = stubs; sandbox.spy(cloud, 'login'); + sandbox.spy(cloud, 'enterOtp'); sandbox.stub(cloud, 'newSpin'); sandbox.stub(cloud, 'stopSpin'); cloud.newSpin.returns({ start: sandbox.stub() }); sandbox.stub(api, 'login'); + sandbox.stub(api, 'sendOtp'); sandbox.stub(api, 'getUser'); sandbox.stub(prompts, 'getCredentials'); + sandbox.stub(prompts, 'getOtp'); sandbox.stub(settings, 'override'); return { cloud, api, prompts, settings }; } diff --git a/test/lib/SerialBatchParser.spec.js b/test/lib/SerialBatchParser.spec.js index 88c5a70c3..9841f5cb6 100644 --- a/test/lib/SerialBatchParser.spec.js +++ b/test/lib/SerialBatchParser.spec.js @@ -9,7 +9,7 @@ var expect = chai.expect; var util = require('util'); var Readable = require('stream').Readable; -var SerialBatchParser = require('../../dist/lib/SerialBatchParser'); +var SerialBatchParser = require('../../src/lib/SerialBatchParser'); function MockStream() { diff --git a/test/lib/SerialTrigger.spec.js b/test/lib/SerialTrigger.spec.js index b8d7f8b4c..57e116556 100644 --- a/test/lib/SerialTrigger.spec.js +++ b/test/lib/SerialTrigger.spec.js @@ -4,7 +4,7 @@ var util = require('util'); var MockSerial = require('../mocks/Serial.mock') var Transform = require('stream').Transform; -var SerialTrigger = require('../../dist/lib/SerialTrigger'); +var SerialTrigger = require('../../src/lib/SerialTrigger'); function PassthroughStream() { Transform.call(this); diff --git a/test/lib/deviceSpecs.spec.js b/test/lib/deviceSpecs.spec.js index 111d62a2b..94a5607c2 100644 --- a/test/lib/deviceSpecs.spec.js +++ b/test/lib/deviceSpecs.spec.js @@ -1,5 +1,5 @@ import {expect, sinon} from '../test-setup'; -import deviceSpecs from '../../dist/lib/deviceSpecs'; +import deviceSpecs from '../../src/lib/deviceSpecs'; describe('deviceSpecs', function() { describe('deviceId', function() { diff --git a/test/lib/dfu.spec.js b/test/lib/dfu.spec.js index 8cc675b16..a1852c77f 100644 --- a/test/lib/dfu.spec.js +++ b/test/lib/dfu.spec.js @@ -1,10 +1,10 @@ 'use strict'; -var dfu = require('../../dist/lib/dfu'); +var dfu = require('../../src/lib/dfu'); var fs = require('fs'); var path = require('path'); var assert = require('assert'); -var specs = require('../../dist/lib/deviceSpecs'); +var specs = require('../../src/lib/deviceSpecs'); var sinon = require('sinon'); const chai = require('chai');