1 | 'use strict'
|
2 |
|
3 | const BB = require('bluebird')
|
4 |
|
5 | const ansistyles = require('ansistyles')
|
6 | const figgyPudding = require('figgy-pudding')
|
7 | const inspect = require('util').inspect
|
8 | const log = require('npmlog')
|
9 | const npm = require('./npm.js')
|
10 | const npmConfig = require('./config/figgy-config.js')
|
11 | const otplease = require('./utils/otplease.js')
|
12 | const output = require('./utils/output.js')
|
13 | const profile = require('libnpm/profile')
|
14 | const pulseTillDone = require('./utils/pulse-till-done.js')
|
15 | const qrcodeTerminal = require('qrcode-terminal')
|
16 | const queryString = require('query-string')
|
17 | const qw = require('qw')
|
18 | const readUserInfo = require('./utils/read-user-info.js')
|
19 | const Table = require('cli-table3')
|
20 | const url = require('url')
|
21 |
|
22 | module.exports = profileCmd
|
23 |
|
24 | profileCmd.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 |
|
30 | profileCmd.subcommands = qw`enable-2fa disable-2fa get set`
|
31 |
|
32 | profileCmd.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 |
|
52 | function withCb (prom, cb) {
|
53 | prom.then((value) => cb(null, value), cb)
|
54 | }
|
55 |
|
56 | const ProfileOpts = figgyPudding({
|
57 | json: {},
|
58 | otp: {},
|
59 | parseable: {},
|
60 | registry: {}
|
61 | })
|
62 |
|
63 | function 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 |
|
90 | const knownProfileKeys = qw`
|
91 | name email ${'two-factor auth'} fullname homepage
|
92 | freenode twitter github created updated`
|
93 |
|
94 | function 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 |
|
138 | const writableProfileKeys = qw`
|
139 | email password fullname homepage freenode twitter github`
|
140 |
|
141 | function 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 |
|
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 |
|
201 | function 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 |
|
227 |
|
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 |
|
285 | function 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 |
|
303 | function 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 |
|
332 | function qrcode (url) {
|
333 | return new Promise((resolve) => qrcodeTerminal.generate(url, resolve))
|
334 | }
|