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