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