]> git.proxmox.com Git - proxmox-widget-toolkit.git/blob - src/window/TfaWindow.js
tfa login: hide u2f and yubico-otp if not available
[proxmox-widget-toolkit.git] / src / window / TfaWindow.js
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;
48 for (const k of ['webauthn', 'totp', 'recovery', 'u2f', 'yubico']) {
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
146 loginYubico: function() {
147 let me = this;
148
149 let code = me.lookup('yubico').getValue();
150 let _promise = me.finishChallenge(`yubico:${code}`);
151 },
152
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>'
387 + Ext.String.format(
388 gettext('Less than {0} recovery keys available. Please generate a new set after login!'),
389 4,
390 ),
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 },
403 tabConfig: {
404 bind: {
405 hidden: '{!availableChallenge.u2f}',
406 },
407 },
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 },
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 },
438 tabConfig: {
439 bind: {
440 hidden: '{!availableChallenge.yubico}',
441 },
442 },
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 },
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 });