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