]>
Commit | Line | Data |
---|---|---|
9e1f1ef6 WB |
1 | /*global u2f*/ |
2 | Ext.define('Proxmox.window.TfaLoginWindow', { | |
3 | extend: 'Ext.window.Window', | |
4 | mixins: ['Proxmox.Mixin.CBind'], | |
5 | ||
6 | title: gettext("Second login factor required"), | |
7 | ||
8 | modal: true, | |
9 | resizable: false, | |
10 | width: 512, | |
11 | layout: { | |
12 | type: 'vbox', | |
13 | align: 'stretch', | |
14 | }, | |
15 | ||
16 | defaultButton: 'tfaButton', | |
17 | ||
18 | viewModel: { | |
19 | data: { | |
20 | confirmText: gettext('Confirm Second Factor'), | |
21 | canConfirm: false, | |
22 | availableChallenge: {}, | |
23 | }, | |
24 | }, | |
25 | ||
26 | cancelled: true, | |
27 | ||
28 | controller: { | |
29 | xclass: 'Ext.app.ViewController', | |
30 | ||
31 | init: function(view) { | |
32 | let me = this; | |
33 | let vm = me.getViewModel(); | |
34 | ||
35 | if (!view.userid) { | |
36 | throw "no userid given"; | |
37 | } | |
38 | if (!view.ticket) { | |
39 | throw "no ticket given"; | |
40 | } | |
41 | const challenge = view.challenge; | |
42 | if (!challenge) { | |
43 | throw "no challenge given"; | |
44 | } | |
45 | ||
46 | let lastTabId = me.getLastTabUsed(); | |
47 | let initialTab = -1, i = 0; | |
20b39dd8 | 48 | for (const k of ['webauthn', 'totp', 'recovery', 'u2f', 'yubico']) { |
9e1f1ef6 WB |
49 | const available = !!challenge[k]; |
50 | vm.set(`availableChallenge.${k}`, available); | |
51 | ||
52 | if (available) { | |
53 | if (i === lastTabId) { | |
54 | initialTab = i; | |
55 | } else if (initialTab < 0) { | |
56 | initialTab = i; | |
57 | } | |
58 | } | |
59 | i++; | |
60 | } | |
61 | view.down('tabpanel').setActiveTab(initialTab); | |
62 | ||
63 | if (challenge.recovery) { | |
64 | me.lookup('availableRecovery').update(Ext.String.htmlEncode( | |
65 | gettext('Available recovery keys: ') + view.challenge.recovery.join(', '), | |
66 | )); | |
67 | me.lookup('availableRecovery').setVisible(true); | |
68 | if (view.challenge.recovery.length <= 3) { | |
69 | me.lookup('recoveryLow').setVisible(true); | |
70 | } | |
71 | } | |
72 | ||
73 | if (challenge.webauthn && initialTab === 0) { | |
74 | let _promise = me.loginWebauthn(); | |
75 | } else if (challenge.u2f && initialTab === 3) { | |
76 | let _promise = me.loginU2F(); | |
77 | } | |
78 | }, | |
79 | control: { | |
80 | 'tabpanel': { | |
81 | tabchange: function(tabPanel, newCard, oldCard) { | |
82 | // for now every TFA method has at max one field, so keep it simple.. | |
83 | let oldField = oldCard.down('field'); | |
84 | if (oldField) { | |
85 | oldField.setDisabled(true); | |
86 | } | |
87 | let newField = newCard.down('field'); | |
88 | if (newField) { | |
89 | newField.setDisabled(false); | |
90 | newField.focus(); | |
91 | newField.validate(); | |
92 | } | |
93 | ||
94 | let confirmText = newCard.confirmText || gettext('Confirm Second Factor'); | |
95 | this.getViewModel().set('confirmText', confirmText); | |
96 | ||
97 | this.saveLastTabUsed(tabPanel, newCard); | |
98 | }, | |
99 | }, | |
100 | 'field': { | |
101 | validitychange: function(field, valid) { | |
102 | // triggers only for enabled fields and we disable the one from the | |
103 | // non-visible tab, so we can just directly use the valid param | |
104 | this.getViewModel().set('canConfirm', valid); | |
105 | }, | |
106 | afterrender: field => field.focus(), // ensure focus after initial render | |
107 | }, | |
108 | }, | |
109 | ||
110 | saveLastTabUsed: function(tabPanel, card) { | |
111 | let id = tabPanel.items.indexOf(card); | |
112 | window.localStorage.setItem('Proxmox.TFALogin.lastTab', JSON.stringify({ id })); | |
113 | }, | |
114 | ||
115 | getLastTabUsed: function() { | |
116 | let data = window.localStorage.getItem('Proxmox.TFALogin.lastTab'); | |
117 | if (typeof data === 'string') { | |
118 | let last = JSON.parse(data); | |
119 | return last.id; | |
120 | } | |
121 | return null; | |
122 | }, | |
123 | ||
124 | onClose: function() { | |
125 | let me = this; | |
126 | let view = me.getView(); | |
127 | ||
128 | if (!view.cancelled) { | |
129 | return; | |
130 | } | |
131 | ||
132 | view.onReject(); | |
133 | }, | |
134 | ||
135 | cancel: function() { | |
136 | this.getView().close(); | |
137 | }, | |
138 | ||
139 | loginTotp: function() { | |
140 | let me = this; | |
141 | ||
142 | let code = me.lookup('totp').getValue(); | |
143 | let _promise = me.finishChallenge(`totp:${code}`); | |
144 | }, | |
145 | ||
20b39dd8 WB |
146 | loginYubico: function() { |
147 | let me = this; | |
148 | ||
149 | let code = me.lookup('yubico').getValue(); | |
150 | let _promise = me.finishChallenge(`yubico:${code}`); | |
151 | }, | |
152 | ||
9e1f1ef6 WB |
153 | loginWebauthn: async function() { |
154 | let me = this; | |
155 | let view = me.getView(); | |
156 | ||
157 | me.lookup('webAuthnWaiting').setVisible(true); | |
158 | me.lookup('webAuthnError').setVisible(false); | |
159 | ||
160 | let challenge = view.challenge.webauthn; | |
161 | ||
162 | if (typeof challenge.string !== 'string') { | |
163 | // Byte array fixup, keep challenge string: | |
164 | challenge.string = challenge.publicKey.challenge; | |
165 | challenge.publicKey.challenge = Proxmox.Utils.base64url_to_bytes(challenge.string); | |
166 | for (const cred of challenge.publicKey.allowCredentials) { | |
167 | cred.id = Proxmox.Utils.base64url_to_bytes(cred.id); | |
168 | } | |
169 | } | |
170 | ||
171 | let controller = new AbortController(); | |
172 | challenge.signal = controller.signal; | |
173 | ||
174 | let hwrsp; | |
175 | try { | |
176 | //Promise.race( ... | |
177 | hwrsp = await navigator.credentials.get(challenge); | |
178 | } catch (error) { | |
179 | // we do NOT want to fail login because of canceling the challenge actively, | |
180 | // in some browser that's the only way to switch over to another method as the | |
181 | // disallow user input during the time the challenge is active | |
182 | // checking for error.code === DOMException.ABORT_ERR only works in firefox -.- | |
183 | this.getViewModel().set('canConfirm', true); | |
184 | // FIXME: better handling, show some message, ...? | |
185 | me.lookup('webAuthnError').setData({ | |
186 | error: Ext.htmlEncode(error.toString()), | |
187 | }); | |
188 | me.lookup('webAuthnError').setVisible(true); | |
189 | return; | |
190 | } finally { | |
191 | let waitingMessage = me.lookup('webAuthnWaiting'); | |
192 | if (waitingMessage) { | |
193 | waitingMessage.setVisible(false); | |
194 | } | |
195 | } | |
196 | ||
197 | let response = { | |
198 | id: hwrsp.id, | |
199 | type: hwrsp.type, | |
200 | challenge: challenge.string, | |
201 | rawId: Proxmox.Utils.bytes_to_base64url(hwrsp.rawId), | |
202 | response: { | |
203 | authenticatorData: Proxmox.Utils.bytes_to_base64url( | |
204 | hwrsp.response.authenticatorData, | |
205 | ), | |
206 | clientDataJSON: Proxmox.Utils.bytes_to_base64url(hwrsp.response.clientDataJSON), | |
207 | signature: Proxmox.Utils.bytes_to_base64url(hwrsp.response.signature), | |
208 | }, | |
209 | }; | |
210 | ||
211 | await me.finishChallenge("webauthn:" + JSON.stringify(response)); | |
212 | }, | |
213 | ||
214 | loginU2F: async function() { | |
215 | let me = this; | |
216 | let view = me.getView(); | |
217 | ||
218 | me.lookup('u2fWaiting').setVisible(true); | |
219 | me.lookup('u2fError').setVisible(false); | |
220 | ||
221 | let hwrsp; | |
222 | try { | |
223 | hwrsp = await new Promise((resolve, reject) => { | |
224 | try { | |
225 | let data = view.challenge.u2f; | |
226 | let chlg = data.challenge; | |
227 | u2f.sign(chlg.appId, chlg.challenge, data.keys, resolve); | |
228 | } catch (error) { | |
229 | reject(error); | |
230 | } | |
231 | }); | |
232 | if (hwrsp.errorCode) { | |
233 | throw Proxmox.Utils.render_u2f_error(hwrsp.errorCode); | |
234 | } | |
235 | delete hwrsp.errorCode; | |
236 | } catch (error) { | |
237 | this.getViewModel().set('canConfirm', true); | |
238 | me.lookup('u2fError').setData({ | |
239 | error: Ext.htmlEncode(error.toString()), | |
240 | }); | |
241 | me.lookup('u2fError').setVisible(true); | |
242 | return; | |
243 | } finally { | |
244 | let waitingMessage = me.lookup('u2fWaiting'); | |
245 | if (waitingMessage) { | |
246 | waitingMessage.setVisible(false); | |
247 | } | |
248 | } | |
249 | ||
250 | await me.finishChallenge("u2f:" + JSON.stringify(hwrsp)); | |
251 | }, | |
252 | ||
253 | loginRecovery: function() { | |
254 | let me = this; | |
255 | ||
256 | let key = me.lookup('recoveryKey').getValue(); | |
257 | let _promise = me.finishChallenge(`recovery:${key}`); | |
258 | }, | |
259 | ||
260 | loginTFA: function() { | |
261 | let me = this; | |
262 | // avoid triggering more than once during challenge | |
263 | me.getViewModel().set('canConfirm', false); | |
264 | let view = me.getView(); | |
265 | let tfaPanel = view.down('tabpanel').getActiveTab(); | |
266 | me[tfaPanel.handler](); | |
267 | }, | |
268 | ||
269 | finishChallenge: function(password) { | |
270 | let me = this; | |
271 | let view = me.getView(); | |
272 | view.cancelled = false; | |
273 | ||
274 | let params = { | |
275 | username: view.userid, | |
276 | 'tfa-challenge': view.ticket, | |
277 | password, | |
278 | }; | |
279 | ||
280 | let resolve = view.onResolve; | |
281 | let reject = view.onReject; | |
282 | view.close(); | |
283 | ||
284 | return Proxmox.Async.api2({ | |
285 | url: '/api2/extjs/access/ticket', | |
286 | method: 'POST', | |
287 | params, | |
288 | }) | |
289 | .then(resolve) | |
290 | .catch(reject); | |
291 | }, | |
292 | }, | |
293 | ||
294 | listeners: { | |
295 | close: 'onClose', | |
296 | }, | |
297 | ||
298 | items: [{ | |
299 | xtype: 'tabpanel', | |
300 | region: 'center', | |
301 | layout: 'fit', | |
302 | bodyPadding: 10, | |
303 | items: [ | |
304 | { | |
305 | xtype: 'panel', | |
306 | title: 'WebAuthn', | |
307 | iconCls: 'fa fa-fw fa-shield', | |
308 | confirmText: gettext('Start WebAuthn challenge'), | |
309 | handler: 'loginWebauthn', | |
310 | bind: { | |
311 | disabled: '{!availableChallenge.webauthn}', | |
312 | }, | |
313 | items: [ | |
314 | { | |
315 | xtype: 'box', | |
316 | html: gettext('Please insert your authentication device and press its button'), | |
317 | }, | |
318 | { | |
319 | xtype: 'box', | |
320 | html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`, | |
321 | reference: 'webAuthnWaiting', | |
322 | hidden: true, | |
323 | }, | |
324 | { | |
325 | xtype: 'box', | |
326 | data: { | |
327 | error: '', | |
328 | }, | |
329 | tpl: '<i class="fa fa-warning warning"></i> {error}', | |
330 | reference: 'webAuthnError', | |
331 | hidden: true, | |
332 | }, | |
333 | ], | |
334 | }, | |
335 | { | |
336 | xtype: 'panel', | |
337 | title: gettext('TOTP App'), | |
338 | iconCls: 'fa fa-fw fa-clock-o', | |
339 | handler: 'loginTotp', | |
340 | bind: { | |
341 | disabled: '{!availableChallenge.totp}', | |
342 | }, | |
343 | items: [ | |
344 | { | |
345 | xtype: 'textfield', | |
346 | fieldLabel: gettext('Please enter your TOTP verification code'), | |
347 | labelWidth: 300, | |
348 | name: 'totp', | |
349 | disabled: true, | |
350 | reference: 'totp', | |
351 | allowBlank: false, | |
352 | regex: /^[0-9]{2,16}$/, | |
353 | regexText: gettext('TOTP codes usually consist of six decimal digits'), | |
354 | }, | |
355 | ], | |
356 | }, | |
357 | { | |
358 | xtype: 'panel', | |
359 | title: gettext('Recovery Key'), | |
360 | iconCls: 'fa fa-fw fa-file-text-o', | |
361 | handler: 'loginRecovery', | |
362 | bind: { | |
363 | disabled: '{!availableChallenge.recovery}', | |
364 | }, | |
365 | items: [ | |
366 | { | |
367 | xtype: 'box', | |
368 | reference: 'availableRecovery', | |
369 | hidden: true, | |
370 | }, | |
371 | { | |
372 | xtype: 'textfield', | |
373 | fieldLabel: gettext('Please enter one of your single-use recovery keys'), | |
374 | labelWidth: 300, | |
375 | name: 'recoveryKey', | |
376 | disabled: true, | |
377 | reference: 'recoveryKey', | |
378 | allowBlank: false, | |
379 | regex: /^[0-9a-f]{4}(-[0-9a-f]{4}){3}$/, | |
380 | regexText: gettext('Does not look like a valid recovery key'), | |
381 | }, | |
382 | { | |
383 | xtype: 'box', | |
384 | reference: 'recoveryLow', | |
385 | hidden: true, | |
386 | html: '<i class="fa fa-exclamation-triangle warning"></i>' | |
55e47317 DC |
387 | + Ext.String.format( |
388 | gettext('Less than {0} recovery keys available. Please generate a new set after login!'), | |
389 | 4, | |
390 | ), | |
9e1f1ef6 WB |
391 | }, |
392 | ], | |
393 | }, | |
394 | { | |
395 | xtype: 'panel', | |
396 | title: 'U2F', | |
397 | iconCls: 'fa fa-fw fa-shield', | |
398 | confirmText: gettext('Start U2F challenge'), | |
399 | handler: 'loginU2F', | |
400 | bind: { | |
401 | disabled: '{!availableChallenge.u2f}', | |
402 | }, | |
d739e441 TL |
403 | tabConfig: { |
404 | bind: { | |
405 | hidden: '{!availableChallenge.u2f}', | |
406 | }, | |
407 | }, | |
9e1f1ef6 WB |
408 | items: [ |
409 | { | |
410 | xtype: 'box', | |
411 | html: gettext('Please insert your authentication device and press its button'), | |
412 | }, | |
413 | { | |
414 | xtype: 'box', | |
415 | html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`, | |
416 | reference: 'u2fWaiting', | |
417 | hidden: true, | |
418 | }, | |
419 | { | |
420 | xtype: 'box', | |
421 | data: { | |
422 | error: '', | |
423 | }, | |
424 | tpl: '<i class="fa fa-warning warning"></i> {error}', | |
425 | reference: 'u2fError', | |
426 | hidden: true, | |
427 | }, | |
428 | ], | |
429 | }, | |
20b39dd8 WB |
430 | { |
431 | xtype: 'panel', | |
432 | title: gettext('Yubico OTP'), | |
433 | iconCls: 'fa fa-fw fa-yahoo', | |
434 | handler: 'loginYubico', | |
435 | bind: { | |
436 | disabled: '{!availableChallenge.yubico}', | |
437 | }, | |
d739e441 TL |
438 | tabConfig: { |
439 | bind: { | |
440 | hidden: '{!availableChallenge.yubico}', | |
441 | }, | |
442 | }, | |
20b39dd8 WB |
443 | items: [ |
444 | { | |
445 | xtype: 'textfield', | |
446 | fieldLabel: gettext('Please enter your Yubico OTP code'), | |
447 | labelWidth: 300, | |
448 | name: 'yubico', | |
449 | disabled: true, | |
450 | reference: 'yubico', | |
451 | allowBlank: false, | |
452 | regex: /^[a-z0-9]{30,60}$/, // *should* be 44 but not sure if that's "fixed" | |
453 | regexText: gettext('TOTP codes consist of six decimal digits'), | |
454 | }, | |
455 | ], | |
456 | }, | |
9e1f1ef6 WB |
457 | ], |
458 | }], | |
459 | ||
460 | buttons: [ | |
461 | { | |
462 | handler: 'loginTFA', | |
463 | reference: 'tfaButton', | |
464 | disabled: true, | |
465 | bind: { | |
466 | text: '{confirmText}', | |
467 | disabled: '{!canConfirm}', | |
468 | }, | |
469 | }, | |
470 | ], | |
471 | }); |