]>
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: { | |
2dbc1a9a | 257 | confirmText: gettext('Confirm Second Factor'), |
f91481ed TL |
258 | canConfirm: false, |
259 | availabelChallenge: {}, | |
260 | }, | |
261 | }, | |
262 | ||
263 | cancelled: true, | |
fbeac4ea | 264 | |
fbeac4ea WB |
265 | controller: { |
266 | xclass: 'Ext.app.ViewController', | |
267 | ||
268 | init: function(view) { | |
269 | let me = this; | |
f91481ed | 270 | let vm = me.getViewModel(); |
fbeac4ea WB |
271 | |
272 | if (!view.userid) { | |
273 | throw "no userid given"; | |
274 | } | |
fbeac4ea WB |
275 | if (!view.ticket) { |
276 | throw "no ticket given"; | |
277 | } | |
f91481ed TL |
278 | const challenge = view.challenge; |
279 | if (!challenge) { | |
fbeac4ea WB |
280 | throw "no challenge given"; |
281 | } | |
282 | ||
6ee85d57 TL |
283 | let lastTabId = me.getLastTabUsed(); |
284 | let initialTab = -1, i = 0; | |
f91481ed TL |
285 | for (const k of ['webauthn', 'totp', 'recovery']) { |
286 | const available = !!challenge[k]; | |
287 | vm.set(`availabelChallenge.${k}`, available); | |
fbeac4ea | 288 | |
6ee85d57 TL |
289 | if (available) { |
290 | if (i === lastTabId) { | |
291 | initialTab = i; | |
292 | } else if (initialTab < 0) { | |
293 | initialTab = i; | |
294 | } | |
f91481ed TL |
295 | } |
296 | i++; | |
fbeac4ea | 297 | } |
6ee85d57 | 298 | view.down('tabpanel').setActiveTab(initialTab); |
fbeac4ea | 299 | |
f91481ed TL |
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 | } | |
fbeac4ea WB |
308 | } |
309 | ||
a11c8ab4 | 310 | if (challenge.webauthn && initialTab === 0) { |
fbeac4ea WB |
311 | let _promise = me.loginWebauthn(); |
312 | } | |
313 | }, | |
f91481ed TL |
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 | } | |
2dbc1a9a TL |
328 | |
329 | let confirmText = newCard.confirmText || gettext('Confirm Second Factor'); | |
330 | this.getViewModel().set('confirmText', confirmText); | |
331 | ||
6ee85d57 | 332 | this.saveLastTabUsed(tabPanel, newCard); |
f91481ed TL |
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 | }, | |
b5c60881 | 341 | afterrender: field => field.focus(), // ensure focus after initial render |
f91481ed TL |
342 | }, |
343 | }, | |
fbeac4ea | 344 | |
6ee85d57 TL |
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 | ||
fbeac4ea WB |
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 | ||
f91481ed TL |
377 | let code = me.lookup('totp').getValue(); |
378 | let _promise = me.finishChallenge(`totp:${code}`); | |
fbeac4ea WB |
379 | }, |
380 | ||
381 | loginWebauthn: async function() { | |
382 | let me = this; | |
383 | let view = me.getView(); | |
384 | ||
f91481ed | 385 | me.lookup('webAuthnWaiting').setVisible(true); |
fbeac4ea WB |
386 | |
387 | let challenge = view.challenge.webauthn; | |
388 | ||
e90fdf5b TL |
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 | for (const cred of challenge.publicKey.allowCredentials) { | |
394 | cred.id = PBS.Utils.base64url_to_bytes(cred.id); | |
395 | } | |
fbeac4ea WB |
396 | } |
397 | ||
f91481ed TL |
398 | let controller = new AbortController(); |
399 | challenge.signal = controller.signal; | |
400 | ||
fbeac4ea WB |
401 | let hwrsp; |
402 | try { | |
f91481ed | 403 | //Promise.race( ... |
fbeac4ea WB |
404 | hwrsp = await navigator.credentials.get(challenge); |
405 | } catch (error) { | |
e90fdf5b TL |
406 | // we do NOT want to fail login because of canceling the challenge actively, |
407 | // in some browser that's the only way to switch over to another method as the | |
408 | // disallow user input during the time the challenge is active | |
409 | // checking for error.code === DOMException.ABORT_ERR only works in firefox -.- | |
410 | this.getViewModel().set('canConfirm', true); | |
411 | // FIXME: better handling, show some message, ...? | |
fbeac4ea WB |
412 | return; |
413 | } finally { | |
f91481ed TL |
414 | let waitingMessage = me.lookup('webAuthnWaiting'); |
415 | if (waitingMessage) { | |
416 | waitingMessage.setVisible(false); | |
417 | } | |
fbeac4ea WB |
418 | } |
419 | ||
420 | let response = { | |
421 | id: hwrsp.id, | |
422 | type: hwrsp.type, | |
e90fdf5b | 423 | challenge: challenge.string, |
fbeac4ea WB |
424 | rawId: PBS.Utils.bytes_to_base64url(hwrsp.rawId), |
425 | response: { | |
426 | authenticatorData: PBS.Utils.bytes_to_base64url( | |
427 | hwrsp.response.authenticatorData, | |
428 | ), | |
429 | clientDataJSON: PBS.Utils.bytes_to_base64url(hwrsp.response.clientDataJSON), | |
430 | signature: PBS.Utils.bytes_to_base64url(hwrsp.response.signature), | |
431 | }, | |
432 | }; | |
433 | ||
fbeac4ea WB |
434 | await me.finishChallenge("webauthn:" + JSON.stringify(response)); |
435 | }, | |
436 | ||
437 | loginRecovery: function() { | |
438 | let me = this; | |
fbeac4ea | 439 | |
f91481ed TL |
440 | let key = me.lookup('recoveryKey').getValue(); |
441 | let _promise = me.finishChallenge(`recovery:${key}`); | |
442 | }, | |
443 | ||
444 | loginTFA: function() { | |
445 | let me = this; | |
8c8f7b5a TL |
446 | // avoid triggering more than once during challenge |
447 | me.getViewModel().set('canConfirm', false); | |
f91481ed TL |
448 | let view = me.getView(); |
449 | let tfaPanel = view.down('tabpanel').getActiveTab(); | |
450 | me[tfaPanel.handler](); | |
fbeac4ea WB |
451 | }, |
452 | ||
453 | finishChallenge: function(password) { | |
454 | let me = this; | |
455 | let view = me.getView(); | |
456 | view.cancelled = false; | |
457 | ||
458 | let params = { | |
459 | username: view.userid, | |
460 | 'tfa-challenge': view.ticket, | |
461 | password, | |
462 | }; | |
463 | ||
464 | let resolve = view.onResolve; | |
465 | let reject = view.onReject; | |
466 | view.close(); | |
467 | ||
468 | return PBS.Async.api2({ | |
469 | url: '/api2/extjs/access/ticket', | |
470 | method: 'POST', | |
471 | params, | |
472 | }) | |
473 | .then(resolve) | |
474 | .catch(reject); | |
475 | }, | |
476 | }, | |
477 | ||
478 | listeners: { | |
479 | close: 'onClose', | |
480 | }, | |
481 | ||
f91481ed TL |
482 | items: [{ |
483 | xtype: 'tabpanel', | |
484 | region: 'center', | |
485 | layout: 'fit', | |
486 | bodyPadding: 10, | |
f91481ed TL |
487 | items: [ |
488 | { | |
489 | xtype: 'panel', | |
490 | title: 'WebAuthn', | |
491 | iconCls: 'fa fa-fw fa-shield', | |
2dbc1a9a | 492 | confirmText: gettext('Start WebAuthn challenge'), |
f91481ed TL |
493 | handler: 'loginWebauthn', |
494 | bind: { | |
495 | disabled: '{!availabelChallenge.webauthn}', | |
fbeac4ea | 496 | }, |
f91481ed TL |
497 | items: [ |
498 | { | |
499 | xtype: 'box', | |
44915932 | 500 | html: gettext('Please insert your authentication device and press its button'), |
f91481ed TL |
501 | }, |
502 | { | |
503 | xtype: 'box', | |
44915932 | 504 | html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`, |
f91481ed TL |
505 | reference: 'webAuthnWaiting', |
506 | hidden: true, | |
507 | }, | |
508 | ], | |
fbeac4ea | 509 | }, |
f91481ed TL |
510 | { |
511 | xtype: 'panel', | |
512 | title: gettext('TOTP App'), | |
513 | iconCls: 'fa fa-fw fa-clock-o', | |
514 | handler: 'loginTotp', | |
515 | bind: { | |
516 | disabled: '{!availabelChallenge.totp}', | |
517 | }, | |
518 | items: [ | |
519 | { | |
520 | xtype: 'textfield', | |
521 | fieldLabel: gettext('Please enter your TOTP verification code'), | |
522 | labelWidth: 300, | |
523 | name: 'totp', | |
524 | disabled: true, | |
525 | reference: 'totp', | |
526 | allowBlank: false, | |
527 | regex: /^[0-9]{6}$/, | |
a65eb0ec | 528 | regexText: gettext('TOTP codes consist of six decimal digits'), |
f91481ed TL |
529 | }, |
530 | ], | |
8ae6d28c | 531 | }, |
f91481ed TL |
532 | { |
533 | xtype: 'panel', | |
534 | title: gettext('Recovery Key'), | |
535 | iconCls: 'fa fa-fw fa-file-text-o', | |
536 | handler: 'loginRecovery', | |
537 | bind: { | |
538 | disabled: '{!availabelChallenge.recovery}', | |
539 | }, | |
540 | items: [ | |
541 | { | |
542 | xtype: 'box', | |
543 | reference: 'availableRecovery', | |
544 | hidden: true, | |
545 | }, | |
546 | { | |
547 | xtype: 'textfield', | |
548 | fieldLabel: gettext('Please enter one of your single-use recovery keys'), | |
549 | labelWidth: 300, | |
550 | name: 'recoveryKey', | |
551 | disabled: true, | |
552 | reference: 'recoveryKey', | |
553 | allowBlank: false, | |
554 | regex: /^[0-9a-f]{4}(-[0-9a-f]{4}){3}$/, | |
a65eb0ec | 555 | regexText: gettext('Does not look like a valid recovery key'), |
f91481ed | 556 | }, |
f91481ed TL |
557 | { |
558 | xtype: 'box', | |
559 | reference: 'recoveryLow', | |
560 | hidden: true, | |
561 | html: '<i class="fa fa-exclamation-triangle warning"></i>' | |
bd768c33 | 562 | + gettext('Less than {0} recovery keys available. Please generate a new set after login!'), |
f91481ed TL |
563 | }, |
564 | ], | |
fbeac4ea | 565 | }, |
f91481ed TL |
566 | ], |
567 | }], | |
fbeac4ea WB |
568 | |
569 | buttons: [ | |
570 | { | |
f91481ed TL |
571 | handler: 'loginTFA', |
572 | reference: 'tfaButton', | |
573 | disabled: true, | |
574 | bind: { | |
2dbc1a9a | 575 | text: '{confirmText}', |
f91481ed TL |
576 | disabled: '{!canConfirm}', |
577 | }, | |
fbeac4ea WB |
578 | }, |
579 | ], | |
580 | }); |