UNPKG

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