]> git.proxmox.com Git - proxmox-backup.git/blame - www/LoginView.js
ui: tfa: webautn: move spinning icon down to waiting message
[proxmox-backup.git] / www / LoginView.js
CommitLineData
34f956bc
DM
1Ext.define('PBS.LoginView', {
2 extend: 'Ext.container.Container',
3 xtype: 'loginview',
4
5 controller: {
6 xclass: 'Ext.app.ViewController',
7
fbeac4ea 8 submitForm: async function() {
34f956bc 9 var me = this;
34f956bc 10 var loginForm = me.lookupReference('loginForm');
9abcae1b
DM
11 var unField = me.lookupReference('usernameField');
12 var saveunField = me.lookupReference('saveunField');
34f956bc 13
9abcae1b
DM
14 if (!loginForm.isValid()) {
15 return;
16 }
17
18 let params = loginForm.getValues();
19
20 params.username = params.username + '@' + params.realm;
8acd4d9a 21 delete params.realm;
9abcae1b
DM
22
23 if (loginForm.isVisible()) {
24 loginForm.mask(gettext('Please wait...'), 'x-mask-loading');
34f956bc 25 }
9abcae1b
DM
26
27 // set or clear username
28 var sp = Ext.state.Manager.getProvider();
29 if (saveunField.getValue() === true) {
30 sp.set(unField.getStateId(), unField.getValue());
31 } else {
32 sp.clear(unField.getStateId());
33 }
34 sp.set(saveunField.getStateId(), saveunField.getValue());
35
fbeac4ea
WB
36 try {
37 let resp = await PBS.Async.api2({
38 url: '/api2/extjs/access/ticket',
39 params: params,
40 method: 'POST',
41 });
42
43 let data = resp.result.data;
44 if (data.ticket.startsWith("PBS:!tfa!")) {
45 data = await me.performTFAChallenge(data);
46 }
47
48 PBS.Utils.updateLoginData(data);
49 PBS.app.changeView('mainview');
50 } catch (error) {
fbeac4ea
WB
51 Proxmox.Utils.authClear();
52 loginForm.unmask();
53 Ext.MessageBox.alert(
54 gettext('Error'),
55 gettext('Login failed. Please try again'),
56 );
57 }
58 },
59
60 performTFAChallenge: async function(data) {
61 let me = this;
62
63 let userid = data.username;
64 let ticket = data.ticket;
65 let challenge = JSON.parse(decodeURIComponent(
66 ticket.split(':')[1].slice("!tfa!".length),
67 ));
68
69 let resp = await new Promise((resolve, reject) => {
70 Ext.create('PBS.login.TfaWindow', {
71 userid,
72 ticket,
73 challenge,
74 onResolve: value => resolve(value),
75 onReject: reject,
76 }).show();
9abcae1b 77 });
fbeac4ea
WB
78
79 return resp.result.data;
34f956bc
DM
80 },
81
82 control: {
9abcae1b
DM
83 'field[name=username]': {
84 specialkey: function(f, e) {
85 if (e.getKey() === e.ENTER) {
86 var pf = this.lookupReference('passwordField');
87 if (!pf.getValue()) {
88 pf.focus(false);
89 }
90 }
8acd4d9a 91 },
9abcae1b
DM
92 },
93 'field[name=lang]': {
94 change: function(f, value) {
95 var dt = Ext.Date.add(new Date(), Ext.Date.YEAR, 10);
96 Ext.util.Cookies.set('PBSLangCookie', value, dt);
97 this.getView().mask(gettext('Please wait...'), 'x-mask-loading');
98 window.location.reload();
8acd4d9a 99 },
9abcae1b 100 },
34f956bc 101 'button[reference=loginButton]': {
8acd4d9a 102 click: 'submitForm',
9abcae1b
DM
103 },
104 'window[reference=loginwindow]': {
105 show: function() {
106 var sp = Ext.state.Manager.getProvider();
107 var checkboxField = this.lookupReference('saveunField');
108 var unField = this.lookupReference('usernameField');
109
110 var checked = sp.get(checkboxField.getStateId());
111 checkboxField.setValue(checked);
112
8acd4d9a 113 if (checked === true) {
9abcae1b
DM
114 var username = sp.get(unField.getStateId());
115 unField.setValue(username);
116 var pwField = this.lookupReference('passwordField');
117 pwField.focus();
118 }
8acd4d9a
TL
119 },
120 },
121 },
34f956bc
DM
122 },
123
124 plugins: 'viewport',
125
126 layout: {
8acd4d9a 127 type: 'border',
34f956bc
DM
128 },
129
130 items: [
131 {
132 region: 'north',
133 xtype: 'container',
134 layout: {
135 type: 'hbox',
8acd4d9a 136 align: 'middle',
34f956bc
DM
137 },
138 margin: '2 5 2 5',
139 height: 38,
140 items: [
141 {
1d8ef0dc
DC
142 xtype: 'proxmoxlogo',
143 prefix: '',
34f956bc
DM
144 },
145 {
146 xtype: 'versioninfo',
8acd4d9a
TL
147 makeApiCall: false,
148 },
149 ],
34f956bc
DM
150 },
151 {
8acd4d9a 152 region: 'center',
34f956bc
DM
153 },
154 {
155 xtype: 'window',
156 closable: false,
157 resizable: false,
158 reference: 'loginwindow',
159 autoShow: true,
160 modal: true,
9abcae1b 161 width: 400,
34f956bc 162
9abcae1b 163 defaultFocus: 'usernameField',
34f956bc
DM
164
165 layout: {
8acd4d9a 166 type: 'auto',
34f956bc
DM
167 },
168
169 title: gettext('Proxmox Backup Server Login'),
170
171 items: [
172 {
173 xtype: 'form',
174 layout: {
8acd4d9a 175 type: 'form',
34f956bc
DM
176 },
177 defaultButton: 'loginButton',
178 url: '/api2/extjs/access/ticket',
179 reference: 'loginForm',
180
181 fieldDefaults: {
182 labelAlign: 'right',
8acd4d9a 183 allowBlank: false,
34f956bc
DM
184 },
185
186 items: [
187 {
188 xtype: 'textfield',
189 fieldLabel: gettext('User name'),
190 name: 'username',
191 itemId: 'usernameField',
9abcae1b 192 reference: 'usernameField',
8acd4d9a 193 stateId: 'login-username',
34f956bc
DM
194 },
195 {
196 xtype: 'textfield',
197 inputType: 'password',
198 fieldLabel: gettext('Password'),
199 name: 'password',
3a841004
TL
200 itemId: 'passwordField',
201 reference: 'passwordField',
9abcae1b 202 },
1d8ef0dc
DC
203 {
204 xtype: 'pmxRealmComboBox',
8acd4d9a 205 name: 'realm',
1d8ef0dc 206 },
9abcae1b
DM
207 {
208 xtype: 'proxmoxLanguageSelector',
209 fieldLabel: gettext('Language'),
210 value: Ext.util.Cookies.get('PBSLangCookie') || Proxmox.defaultLang || 'en',
211 name: 'lang',
212 reference: 'langField',
8acd4d9a
TL
213 submitValue: false,
214 },
34f956bc
DM
215 ],
216 buttons: [
9abcae1b
DM
217 {
218 xtype: 'checkbox',
219 fieldLabel: gettext('Save User name'),
220 name: 'saveusername',
221 reference: 'saveunField',
222 stateId: 'login-saveusername',
223 labelWidth: 250,
224 labelAlign: 'right',
8acd4d9a 225 submitValue: false,
9abcae1b 226 },
34f956bc
DM
227 {
228 text: gettext('Login'),
229 reference: 'loginButton',
8acd4d9a
TL
230 formBind: true,
231 },
232 ],
233 },
234 ],
235 },
236 ],
34f956bc 237});
fbeac4ea
WB
238
239Ext.define('PBS.login.TfaWindow', {
240 extend: 'Ext.window.Window',
241 mixins: ['Proxmox.Mixin.CBind'],
242
fbeac4ea
WB
243 title: gettext("Second login factor required"),
244
f91481ed
TL
245 modal: true,
246 resizable: false,
fbeac4ea
WB
247 width: 512,
248 layout: {
249 type: 'vbox',
250 align: 'stretch',
251 },
252
f91481ed
TL
253 defaultButton: 'tfaButton',
254
255 viewModel: {
256 data: {
257 canConfirm: false,
258 availabelChallenge: {},
259 },
260 },
261
262 cancelled: true,
fbeac4ea 263
fbeac4ea
WB
264 controller: {
265 xclass: 'Ext.app.ViewController',
266
267 init: function(view) {
268 let me = this;
f91481ed 269 let vm = me.getViewModel();
fbeac4ea
WB
270
271 if (!view.userid) {
272 throw "no userid given";
273 }
fbeac4ea
WB
274 if (!view.ticket) {
275 throw "no ticket given";
276 }
f91481ed
TL
277 const challenge = view.challenge;
278 if (!challenge) {
fbeac4ea
WB
279 throw "no challenge given";
280 }
281
6ee85d57
TL
282 let lastTabId = me.getLastTabUsed();
283 let initialTab = -1, i = 0;
f91481ed
TL
284 for (const k of ['webauthn', 'totp', 'recovery']) {
285 const available = !!challenge[k];
286 vm.set(`availabelChallenge.${k}`, available);
fbeac4ea 287
6ee85d57
TL
288 if (available) {
289 if (i === lastTabId) {
290 initialTab = i;
291 } else if (initialTab < 0) {
292 initialTab = i;
293 }
f91481ed
TL
294 }
295 i++;
fbeac4ea 296 }
6ee85d57 297 view.down('tabpanel').setActiveTab(initialTab);
fbeac4ea 298
f91481ed
TL
299 if (challenge.recovery) {
300 me.lookup('availableRecovery').update(Ext.String.htmlEncode(
301 gettext('Available recovery keys: ') + view.challenge.recovery.join(', '),
302 ));
303 me.lookup('availableRecovery').setVisible(true);
304 if (view.challenge.recovery.length <= 3) {
305 me.lookup('recoveryLow').setVisible(true);
306 }
fbeac4ea
WB
307 }
308
a11c8ab4 309 if (challenge.webauthn && initialTab === 0) {
fbeac4ea
WB
310 let _promise = me.loginWebauthn();
311 }
312 },
f91481ed
TL
313 control: {
314 'tabpanel': {
315 tabchange: function(tabPanel, newCard, oldCard) {
316 // for now every TFA method has at max one field, so keep it simple..
317 let oldField = oldCard.down('field');
318 if (oldField) {
319 oldField.setDisabled(true);
320 }
321 let newField = newCard.down('field');
322 if (newField) {
323 newField.setDisabled(false);
324 newField.focus();
325 newField.validate();
326 }
6ee85d57 327 this.saveLastTabUsed(tabPanel, newCard);
f91481ed
TL
328 },
329 },
330 'field': {
331 validitychange: function(field, valid) {
332 // triggers only for enabled fields and we disable the one from the
333 // non-visible tab, so we can just directly use the valid param
334 this.getViewModel().set('canConfirm', valid);
335 },
336 },
337 },
fbeac4ea 338
6ee85d57
TL
339 saveLastTabUsed: function(tabPanel, card) {
340 let id = tabPanel.items.indexOf(card);
341 window.localStorage.setItem('PBS.TFALogin.lastTab', JSON.stringify({ id }));
342 },
343
344 getLastTabUsed: function() {
345 let data = window.localStorage.getItem('PBS.TFALogin.lastTab');
346 if (typeof data === 'string') {
347 let last = JSON.parse(data);
348 return last.id;
349 }
350 return null;
351 },
352
fbeac4ea
WB
353 onClose: function() {
354 let me = this;
355 let view = me.getView();
356
357 if (!view.cancelled) {
358 return;
359 }
360
361 view.onReject();
362 },
363
364 cancel: function() {
365 this.getView().close();
366 },
367
368 loginTotp: function() {
369 let me = this;
370
f91481ed
TL
371 let code = me.lookup('totp').getValue();
372 let _promise = me.finishChallenge(`totp:${code}`);
fbeac4ea
WB
373 },
374
375 loginWebauthn: async function() {
376 let me = this;
377 let view = me.getView();
378
f91481ed 379 me.lookup('webAuthnWaiting').setVisible(true);
fbeac4ea
WB
380
381 let challenge = view.challenge.webauthn;
382
e90fdf5b
TL
383 if (typeof challenge.string !== 'string') {
384 // Byte array fixup, keep challenge string:
385 challenge.string = challenge.publicKey.challenge;
386 challenge.publicKey.challenge = PBS.Utils.base64url_to_bytes(challenge.string);
387 for (const cred of challenge.publicKey.allowCredentials) {
388 cred.id = PBS.Utils.base64url_to_bytes(cred.id);
389 }
fbeac4ea
WB
390 }
391
f91481ed
TL
392 let controller = new AbortController();
393 challenge.signal = controller.signal;
394
fbeac4ea
WB
395 let hwrsp;
396 try {
f91481ed 397 //Promise.race( ...
fbeac4ea
WB
398 hwrsp = await navigator.credentials.get(challenge);
399 } catch (error) {
e90fdf5b
TL
400 // we do NOT want to fail login because of canceling the challenge actively,
401 // in some browser that's the only way to switch over to another method as the
402 // disallow user input during the time the challenge is active
403 // checking for error.code === DOMException.ABORT_ERR only works in firefox -.-
404 this.getViewModel().set('canConfirm', true);
405 // FIXME: better handling, show some message, ...?
fbeac4ea
WB
406 return;
407 } finally {
f91481ed
TL
408 let waitingMessage = me.lookup('webAuthnWaiting');
409 if (waitingMessage) {
410 waitingMessage.setVisible(false);
411 }
fbeac4ea
WB
412 }
413
414 let response = {
415 id: hwrsp.id,
416 type: hwrsp.type,
e90fdf5b 417 challenge: challenge.string,
fbeac4ea
WB
418 rawId: PBS.Utils.bytes_to_base64url(hwrsp.rawId),
419 response: {
420 authenticatorData: PBS.Utils.bytes_to_base64url(
421 hwrsp.response.authenticatorData,
422 ),
423 clientDataJSON: PBS.Utils.bytes_to_base64url(hwrsp.response.clientDataJSON),
424 signature: PBS.Utils.bytes_to_base64url(hwrsp.response.signature),
425 },
426 };
427
fbeac4ea
WB
428 await me.finishChallenge("webauthn:" + JSON.stringify(response));
429 },
430
431 loginRecovery: function() {
432 let me = this;
fbeac4ea 433
f91481ed
TL
434 let key = me.lookup('recoveryKey').getValue();
435 let _promise = me.finishChallenge(`recovery:${key}`);
436 },
437
438 loginTFA: function() {
439 let me = this;
440 let view = me.getView();
441 let tfaPanel = view.down('tabpanel').getActiveTab();
442 me[tfaPanel.handler]();
fbeac4ea
WB
443 },
444
445 finishChallenge: function(password) {
446 let me = this;
447 let view = me.getView();
448 view.cancelled = false;
449
450 let params = {
451 username: view.userid,
452 'tfa-challenge': view.ticket,
453 password,
454 };
455
456 let resolve = view.onResolve;
457 let reject = view.onReject;
458 view.close();
459
460 return PBS.Async.api2({
461 url: '/api2/extjs/access/ticket',
462 method: 'POST',
463 params,
464 })
465 .then(resolve)
466 .catch(reject);
467 },
468 },
469
470 listeners: {
471 close: 'onClose',
472 },
473
f91481ed
TL
474 items: [{
475 xtype: 'tabpanel',
476 region: 'center',
477 layout: 'fit',
478 bodyPadding: 10,
479 stateId: 'pbs-tfa-login-panel', // FIXME: do manually - get/setState miss
480 stateful: true,
481 stateEvents: ['tabchange'],
482 items: [
483 {
484 xtype: 'panel',
485 title: 'WebAuthn',
486 iconCls: 'fa fa-fw fa-shield',
487 handler: 'loginWebauthn',
488 bind: {
489 disabled: '{!availabelChallenge.webauthn}',
fbeac4ea 490 },
f91481ed
TL
491 items: [
492 {
493 xtype: 'box',
44915932 494 html: gettext('Please insert your authentication device and press its button'),
f91481ed
TL
495 },
496 {
497 xtype: 'box',
44915932 498 html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`,
f91481ed
TL
499 reference: 'webAuthnWaiting',
500 hidden: true,
501 },
502 ],
fbeac4ea 503 },
f91481ed
TL
504 {
505 xtype: 'panel',
506 title: gettext('TOTP App'),
507 iconCls: 'fa fa-fw fa-clock-o',
508 handler: 'loginTotp',
509 bind: {
510 disabled: '{!availabelChallenge.totp}',
511 },
512 items: [
513 {
514 xtype: 'textfield',
515 fieldLabel: gettext('Please enter your TOTP verification code'),
516 labelWidth: 300,
517 name: 'totp',
518 disabled: true,
519 reference: 'totp',
520 allowBlank: false,
521 regex: /^[0-9]{6}$/,
522 regexText: 'TOTP codes consist of six decimal digits',
523 },
524 ],
8ae6d28c 525 },
f91481ed
TL
526 {
527 xtype: 'panel',
528 title: gettext('Recovery Key'),
529 iconCls: 'fa fa-fw fa-file-text-o',
530 handler: 'loginRecovery',
531 bind: {
532 disabled: '{!availabelChallenge.recovery}',
533 },
534 items: [
535 {
536 xtype: 'box',
537 reference: 'availableRecovery',
538 hidden: true,
539 },
540 {
541 xtype: 'textfield',
542 fieldLabel: gettext('Please enter one of your single-use recovery keys'),
543 labelWidth: 300,
544 name: 'recoveryKey',
545 disabled: true,
546 reference: 'recoveryKey',
547 allowBlank: false,
548 regex: /^[0-9a-f]{4}(-[0-9a-f]{4}){3}$/,
549 regexText: 'Does not looks like a valid recovery key',
550 },
551 {
552 xtype: 'box',
553 reference: 'recoveryInfo',
554 hidden: true, // FIXME: remove this?
555 html: gettext('Note that each recovery code can only be used once!'),
556 },
557 {
558 xtype: 'box',
559 reference: 'recoveryLow',
560 hidden: true,
561 html: '<i class="fa fa-exclamation-triangle warning"></i>'
562 + gettext('Less than {0} recovery keys available. Please generate a new set!'),
563 },
564 ],
fbeac4ea 565 },
f91481ed
TL
566 ],
567 }],
fbeac4ea
WB
568
569 buttons: [
570 {
f91481ed
TL
571 text: gettext('Confirm Second Factor'),
572 handler: 'loginTFA',
573 reference: 'tfaButton',
574 disabled: true,
575 bind: {
576 disabled: '{!canConfirm}',
577 },
fbeac4ea
WB
578 },
579 ],
580});