• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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