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