profile.js 11 KB

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