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