]>
Commit | Line | Data |
---|---|---|
34f956bc DM |
1 | Ext.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 | |
239 | Ext.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 | }); |