1'use strict' 2 3const BB = require('bluebird') 4 5const ansistyles = require('ansistyles') 6const figgyPudding = require('figgy-pudding') 7const inspect = require('util').inspect 8const log = require('npmlog') 9const npm = require('./npm.js') 10const npmConfig = require('./config/figgy-config.js') 11const otplease = require('./utils/otplease.js') 12const output = require('./utils/output.js') 13const profile = require('libnpm/profile') 14const pulseTillDone = require('./utils/pulse-till-done.js') 15const qrcodeTerminal = require('qrcode-terminal') 16const queryString = require('query-string') 17const qw = require('qw') 18const readUserInfo = require('./utils/read-user-info.js') 19const Table = require('cli-table3') 20const url = require('url') 21 22module.exports = profileCmd 23 24profileCmd.usage = 25 'npm profile enable-2fa [auth-only|auth-and-writes]\n' + 26 'npm profile disable-2fa\n' + 27 'npm profile get [<key>]\n' + 28 'npm profile set <key> <value>' 29 30profileCmd.subcommands = qw`enable-2fa disable-2fa get set` 31 32profileCmd.completion = function (opts, cb) { 33 var argv = opts.conf.argv.remain 34 switch (argv[2]) { 35 case 'enable-2fa': 36 case 'enable-tfa': 37 if (argv.length === 3) { 38 return cb(null, qw`auth-and-writes auth-only`) 39 } else { 40 return cb(null, []) 41 } 42 case 'disable-2fa': 43 case 'disable-tfa': 44 case 'get': 45 case 'set': 46 return cb(null, []) 47 default: 48 return cb(new Error(argv[2] + ' not recognized')) 49 } 50} 51 52function withCb (prom, cb) { 53 prom.then((value) => cb(null, value), cb) 54} 55 56const ProfileOpts = figgyPudding({ 57 json: {}, 58 otp: {}, 59 parseable: {}, 60 registry: {} 61}) 62 63function profileCmd (args, cb) { 64 if (args.length === 0) return cb(new Error(profileCmd.usage)) 65 log.gauge.show('profile') 66 switch (args[0]) { 67 case 'enable-2fa': 68 case 'enable-tfa': 69 case 'enable2fa': 70 case 'enabletfa': 71 withCb(enable2fa(args.slice(1)), cb) 72 break 73 case 'disable-2fa': 74 case 'disable-tfa': 75 case 'disable2fa': 76 case 'disabletfa': 77 withCb(disable2fa(), cb) 78 break 79 case 'get': 80 withCb(get(args.slice(1)), cb) 81 break 82 case 'set': 83 withCb(set(args.slice(1)), cb) 84 break 85 default: 86 cb(new Error('Unknown profile command: ' + args[0])) 87 } 88} 89 90const knownProfileKeys = qw` 91 name email ${'two-factor auth'} fullname homepage 92 freenode twitter github created updated` 93 94function get (args) { 95 const tfa = 'two-factor auth' 96 const conf = ProfileOpts(npmConfig()) 97 return pulseTillDone.withPromise(profile.get(conf)).then((info) => { 98 if (!info.cidr_whitelist) delete info.cidr_whitelist 99 if (conf.json) { 100 output(JSON.stringify(info, null, 2)) 101 return 102 } 103 const cleaned = {} 104 knownProfileKeys.forEach((k) => { cleaned[k] = info[k] || '' }) 105 Object.keys(info).filter((k) => !(k in cleaned)).forEach((k) => { cleaned[k] = info[k] || '' }) 106 delete cleaned.tfa 107 delete cleaned.email_verified 108 cleaned['email'] += info.email_verified ? ' (verified)' : '(unverified)' 109 if (info.tfa && !info.tfa.pending) { 110 cleaned[tfa] = info.tfa.mode 111 } else { 112 cleaned[tfa] = 'disabled' 113 } 114 if (args.length) { 115 const values = args // comma or space separated ↓ 116 .join(',').split(/,/).map((arg) => arg.trim()).filter((arg) => arg !== '') 117 .map((arg) => cleaned[arg]) 118 .join('\t') 119 output(values) 120 } else { 121 if (conf.parseable) { 122 Object.keys(info).forEach((key) => { 123 if (key === 'tfa') { 124 output(`${key}\t${cleaned[tfa]}`) 125 } else { 126 output(`${key}\t${info[key]}`) 127 } 128 }) 129 } else { 130 const table = new Table() 131 Object.keys(cleaned).forEach((k) => table.push({[ansistyles.bright(k)]: cleaned[k]})) 132 output(table.toString()) 133 } 134 } 135 }) 136} 137 138const writableProfileKeys = qw` 139 email password fullname homepage freenode twitter github` 140 141function set (args) { 142 let conf = ProfileOpts(npmConfig()) 143 const prop = (args[0] || '').toLowerCase().trim() 144 let value = args.length > 1 ? args.slice(1).join(' ') : null 145 if (prop !== 'password' && value === null) { 146 return Promise.reject(Error('npm profile set <prop> <value>')) 147 } 148 if (prop === 'password' && value !== null) { 149 return Promise.reject(Error( 150 'npm profile set password\n' + 151 'Do not include your current or new passwords on the command line.')) 152 } 153 if (writableProfileKeys.indexOf(prop) === -1) { 154 return Promise.reject(Error(`"${prop}" is not a property we can set. Valid properties are: ` + writableProfileKeys.join(', '))) 155 } 156 return BB.try(() => { 157 if (prop === 'password') { 158 return readUserInfo.password('Current password: ').then((current) => { 159 return readPasswords().then((newpassword) => { 160 value = {old: current, new: newpassword} 161 }) 162 }) 163 } else if (prop === 'email') { 164 return readUserInfo.password('Password: ').then((current) => { 165 return {password: current, email: value} 166 }) 167 } 168 function readPasswords () { 169 return readUserInfo.password('New password: ').then((password1) => { 170 return readUserInfo.password(' Again: ').then((password2) => { 171 if (password1 !== password2) { 172 log.warn('profile', 'Passwords do not match, please try again.') 173 return readPasswords() 174 } 175 return password1 176 }) 177 }) 178 } 179 }).then(() => { 180 // FIXME: Work around to not clear everything other than what we're setting 181 return pulseTillDone.withPromise(profile.get(conf).then((user) => { 182 const newUser = {} 183 writableProfileKeys.forEach((k) => { newUser[k] = user[k] }) 184 newUser[prop] = value 185 return otplease(conf, conf => profile.set(newUser, conf)) 186 .then((result) => { 187 if (conf.json) { 188 output(JSON.stringify({[prop]: result[prop]}, null, 2)) 189 } else if (conf.parseable) { 190 output(prop + '\t' + result[prop]) 191 } else if (result[prop] != null) { 192 output('Set', prop, 'to', result[prop]) 193 } else { 194 output('Set', prop) 195 } 196 }) 197 })) 198 }) 199} 200 201function enable2fa (args) { 202 if (args.length > 1) { 203 return Promise.reject(new Error('npm profile enable-2fa [auth-and-writes|auth-only]')) 204 } 205 const mode = args[0] || 'auth-and-writes' 206 if (mode !== 'auth-only' && mode !== 'auth-and-writes') { 207 return Promise.reject(new Error(`Invalid two-factor authentication mode "${mode}".\n` + 208 'Valid modes are:\n' + 209 ' auth-only - Require two-factor authentication only when logging in\n' + 210 ' auth-and-writes - Require two-factor authentication when logging in AND when publishing')) 211 } 212 const conf = ProfileOpts(npmConfig()) 213 if (conf.json || conf.parseable) { 214 return Promise.reject(new Error( 215 'Enabling two-factor authentication is an interactive operation and ' + 216 (conf.json ? 'JSON' : 'parseable') + ' output mode is not available')) 217 } 218 219 const info = { 220 tfa: { 221 mode: mode 222 } 223 } 224 225 return BB.try(() => { 226 // if they're using legacy auth currently then we have to update them to a 227 // bearer token before continuing. 228 const auth = getAuth(conf) 229 if (auth.basic) { 230 log.info('profile', 'Updating authentication to bearer token') 231 return profile.createToken( 232 auth.basic.password, false, [], conf 233 ).then((result) => { 234 if (!result.token) throw new Error('Your registry ' + conf.registry + 'does not seem to support bearer tokens. Bearer tokens are required for two-factor authentication') 235 npm.config.setCredentialsByURI(conf.registry, {token: result.token}) 236 return BB.fromNode((cb) => npm.config.save('user', cb)) 237 }) 238 } 239 }).then(() => { 240 log.notice('profile', 'Enabling two factor authentication for ' + mode) 241 return readUserInfo.password() 242 }).then((password) => { 243 info.tfa.password = password 244 log.info('profile', 'Determine if tfa is pending') 245 return pulseTillDone.withPromise(profile.get(conf)).then((info) => { 246 if (!info.tfa) return 247 if (info.tfa.pending) { 248 log.info('profile', 'Resetting two-factor authentication') 249 return pulseTillDone.withPromise(profile.set({tfa: {password, mode: 'disable'}}, conf)) 250 } else { 251 if (conf.auth.otp) return 252 return readUserInfo.otp('Enter one-time password: ').then((otp) => { 253 conf.auth.otp = otp 254 }) 255 } 256 }) 257 }).then(() => { 258 log.info('profile', 'Setting two-factor authentication to ' + mode) 259 return pulseTillDone.withPromise(profile.set(info, conf)) 260 }).then((challenge) => { 261 if (challenge.tfa === null) { 262 output('Two factor authentication mode changed to: ' + mode) 263 return 264 } 265 if (typeof challenge.tfa !== 'string' || !/^otpauth:[/][/]/.test(challenge.tfa)) { 266 throw new Error('Unknown error enabling two-factor authentication. Expected otpauth URL, got: ' + inspect(challenge.tfa)) 267 } 268 const otpauth = url.parse(challenge.tfa) 269 const opts = queryString.parse(otpauth.query) 270 return qrcode(challenge.tfa).then((code) => { 271 output('Scan into your authenticator app:\n' + code + '\n Or enter code:', opts.secret) 272 }).then((code) => { 273 return readUserInfo.otp('And an OTP code from your authenticator: ') 274 }).then((otp1) => { 275 log.info('profile', 'Finalizing two-factor authentication') 276 return profile.set({tfa: [otp1]}, conf) 277 }).then((result) => { 278 output('2FA successfully enabled. Below are your recovery codes, please print these out.') 279 output('You will need these to recover access to your account if you lose your authentication device.') 280 result.tfa.forEach((c) => output('\t' + c)) 281 }) 282 }) 283} 284 285function getAuth (conf) { 286 const creds = npm.config.getCredentialsByURI(conf.registry) 287 let auth 288 if (creds.token) { 289 auth = {token: creds.token} 290 } else if (creds.username) { 291 auth = {basic: {username: creds.username, password: creds.password}} 292 } else if (creds.auth) { 293 const basic = Buffer.from(creds.auth, 'base64').toString().split(':', 2) 294 auth = {basic: {username: basic[0], password: basic[1]}} 295 } else { 296 auth = {} 297 } 298 299 if (conf.otp) auth.otp = conf.otp 300 return auth 301} 302 303function disable2fa (args) { 304 let conf = ProfileOpts(npmConfig()) 305 return pulseTillDone.withPromise(profile.get(conf)).then((info) => { 306 if (!info.tfa || info.tfa.pending) { 307 output('Two factor authentication not enabled.') 308 return 309 } 310 return readUserInfo.password().then((password) => { 311 return BB.try(() => { 312 if (conf.otp) return 313 return readUserInfo.otp('Enter one-time password: ').then((otp) => { 314 conf = conf.concat({otp}) 315 }) 316 }).then(() => { 317 log.info('profile', 'disabling tfa') 318 return pulseTillDone.withPromise(profile.set({tfa: {password: password, mode: 'disable'}}, conf)).then(() => { 319 if (conf.json) { 320 output(JSON.stringify({tfa: false}, null, 2)) 321 } else if (conf.parseable) { 322 output('tfa\tfalse') 323 } else { 324 output('Two factor authentication disabled.') 325 } 326 }) 327 }) 328 }) 329 }) 330} 331 332function qrcode (url) { 333 return new Promise((resolve) => qrcodeTerminal.generate(url, resolve)) 334} 335