UNPKG

11.4 kBJavaScriptView Raw
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 from your authenticator app: ').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 from your authenticator: ').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}