]>
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 | }, | |
341 | }, | |
342 | }, | |
fbeac4ea | 343 | |
6ee85d57 TL |
344 | saveLastTabUsed: function(tabPanel, card) { |
345 | let id = tabPanel.items.indexOf(card); | |
346 | window.localStorage.setItem('PBS.TFALogin.lastTab', JSON.stringify({ id })); | |
347 | }, | |
348 | ||
349 | getLastTabUsed: function() { | |
350 | let data = window.localStorage.getItem('PBS.TFALogin.lastTab'); | |
351 | if (typeof data === 'string') { | |
352 | let last = JSON.parse(data); | |
353 | return last.id; | |
354 | } | |
355 | return null; | |
356 | }, | |
357 | ||
fbeac4ea WB |
358 | onClose: function() { |
359 | let me = this; | |
360 | let view = me.getView(); | |
361 | ||
362 | if (!view.cancelled) { | |
363 | return; | |
364 | } | |
365 | ||
366 | view.onReject(); | |
367 | }, | |
368 | ||
369 | cancel: function() { | |
370 | this.getView().close(); | |
371 | }, | |
372 | ||
373 | loginTotp: function() { | |
374 | let me = this; | |
375 | ||
f91481ed TL |
376 | let code = me.lookup('totp').getValue(); |
377 | let _promise = me.finishChallenge(`totp:${code}`); | |
fbeac4ea WB |
378 | }, |
379 | ||
380 | loginWebauthn: async function() { | |
381 | let me = this; | |
382 | let view = me.getView(); | |
383 | ||
f91481ed | 384 | me.lookup('webAuthnWaiting').setVisible(true); |
fbeac4ea WB |
385 | |
386 | let challenge = view.challenge.webauthn; | |
387 | ||
e90fdf5b TL |
388 | if (typeof challenge.string !== 'string') { |
389 | // Byte array fixup, keep challenge string: | |
390 | challenge.string = challenge.publicKey.challenge; | |
391 | challenge.publicKey.challenge = PBS.Utils.base64url_to_bytes(challenge.string); | |
392 | for (const cred of challenge.publicKey.allowCredentials) { | |
393 | cred.id = PBS.Utils.base64url_to_bytes(cred.id); | |
394 | } | |
fbeac4ea WB |
395 | } |
396 | ||
f91481ed TL |
397 | let controller = new AbortController(); |
398 | challenge.signal = controller.signal; | |
399 | ||
fbeac4ea WB |
400 | let hwrsp; |
401 | try { | |
f91481ed | 402 | //Promise.race( ... |
fbeac4ea WB |
403 | hwrsp = await navigator.credentials.get(challenge); |
404 | } catch (error) { | |
e90fdf5b TL |
405 | // we do NOT want to fail login because of canceling the challenge actively, |
406 | // in some browser that's the only way to switch over to another method as the | |
407 | // disallow user input during the time the challenge is active | |
408 | // checking for error.code === DOMException.ABORT_ERR only works in firefox -.- | |
409 | this.getViewModel().set('canConfirm', true); | |
410 | // FIXME: better handling, show some message, ...? | |
fbeac4ea WB |
411 | return; |
412 | } finally { | |
f91481ed TL |
413 | let waitingMessage = me.lookup('webAuthnWaiting'); |
414 | if (waitingMessage) { | |
415 | waitingMessage.setVisible(false); | |
416 | } | |
fbeac4ea WB |
417 | } |
418 | ||
419 | let response = { | |
420 | id: hwrsp.id, | |
421 | type: hwrsp.type, | |
e90fdf5b | 422 | challenge: challenge.string, |
fbeac4ea WB |
423 | rawId: PBS.Utils.bytes_to_base64url(hwrsp.rawId), |
424 | response: { | |
425 | authenticatorData: PBS.Utils.bytes_to_base64url( | |
426 | hwrsp.response.authenticatorData, | |
427 | ), | |
428 | clientDataJSON: PBS.Utils.bytes_to_base64url(hwrsp.response.clientDataJSON), | |
429 | signature: PBS.Utils.bytes_to_base64url(hwrsp.response.signature), | |
430 | }, | |
431 | }; | |
432 | ||
fbeac4ea WB |
433 | await me.finishChallenge("webauthn:" + JSON.stringify(response)); |
434 | }, | |
435 | ||
436 | loginRecovery: function() { | |
437 | let me = this; | |
fbeac4ea | 438 | |
f91481ed TL |
439 | let key = me.lookup('recoveryKey').getValue(); |
440 | let _promise = me.finishChallenge(`recovery:${key}`); | |
441 | }, | |
442 | ||
443 | loginTFA: function() { | |
444 | let me = this; | |
8c8f7b5a TL |
445 | // avoid triggering more than once during challenge |
446 | me.getViewModel().set('canConfirm', false); | |
f91481ed TL |
447 | let view = me.getView(); |
448 | let tfaPanel = view.down('tabpanel').getActiveTab(); | |
449 | me[tfaPanel.handler](); | |
fbeac4ea WB |
450 | }, |
451 | ||
452 | finishChallenge: function(password) { | |
453 | let me = this; | |
454 | let view = me.getView(); | |
455 | view.cancelled = false; | |
456 | ||
457 | let params = { | |
458 | username: view.userid, | |
459 | 'tfa-challenge': view.ticket, | |
460 | password, | |
461 | }; | |
462 | ||
463 | let resolve = view.onResolve; | |
464 | let reject = view.onReject; | |
465 | view.close(); | |
466 | ||
467 | return PBS.Async.api2({ | |
468 | url: '/api2/extjs/access/ticket', | |
469 | method: 'POST', | |
470 | params, | |
471 | }) | |
472 | .then(resolve) | |
473 | .catch(reject); | |
474 | }, | |
475 | }, | |
476 | ||
477 | listeners: { | |
478 | close: 'onClose', | |
479 | }, | |
480 | ||
f91481ed TL |
481 | items: [{ |
482 | xtype: 'tabpanel', | |
483 | region: 'center', | |
484 | layout: 'fit', | |
485 | bodyPadding: 10, | |
f91481ed TL |
486 | items: [ |
487 | { | |
488 | xtype: 'panel', | |
489 | title: 'WebAuthn', | |
490 | iconCls: 'fa fa-fw fa-shield', | |
2dbc1a9a | 491 | confirmText: gettext('Start WebAuthn challenge'), |
f91481ed TL |
492 | handler: 'loginWebauthn', |
493 | bind: { | |
494 | disabled: '{!availabelChallenge.webauthn}', | |
fbeac4ea | 495 | }, |
f91481ed TL |
496 | items: [ |
497 | { | |
498 | xtype: 'box', | |
44915932 | 499 | html: gettext('Please insert your authentication device and press its button'), |
f91481ed TL |
500 | }, |
501 | { | |
502 | xtype: 'box', | |
44915932 | 503 | html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`, |
f91481ed TL |
504 | reference: 'webAuthnWaiting', |
505 | hidden: true, | |
506 | }, | |
507 | ], | |
fbeac4ea | 508 | }, |
f91481ed TL |
509 | { |
510 | xtype: 'panel', | |
511 | title: gettext('TOTP App'), | |
512 | iconCls: 'fa fa-fw fa-clock-o', | |
513 | handler: 'loginTotp', | |
514 | bind: { | |
515 | disabled: '{!availabelChallenge.totp}', | |
516 | }, | |
517 | items: [ | |
518 | { | |
519 | xtype: 'textfield', | |
520 | fieldLabel: gettext('Please enter your TOTP verification code'), | |
521 | labelWidth: 300, | |
522 | name: 'totp', | |
523 | disabled: true, | |
524 | reference: 'totp', | |
525 | allowBlank: false, | |
526 | regex: /^[0-9]{6}$/, | |
527 | regexText: 'TOTP codes consist of six decimal digits', | |
528 | }, | |
529 | ], | |
8ae6d28c | 530 | }, |
f91481ed TL |
531 | { |
532 | xtype: 'panel', | |
533 | title: gettext('Recovery Key'), | |
534 | iconCls: 'fa fa-fw fa-file-text-o', | |
535 | handler: 'loginRecovery', | |
536 | bind: { | |
537 | disabled: '{!availabelChallenge.recovery}', | |
538 | }, | |
539 | items: [ | |
540 | { | |
541 | xtype: 'box', | |
542 | reference: 'availableRecovery', | |
543 | hidden: true, | |
544 | }, | |
545 | { | |
546 | xtype: 'textfield', | |
547 | fieldLabel: gettext('Please enter one of your single-use recovery keys'), | |
548 | labelWidth: 300, | |
549 | name: 'recoveryKey', | |
550 | disabled: true, | |
551 | reference: 'recoveryKey', | |
552 | allowBlank: false, | |
553 | regex: /^[0-9a-f]{4}(-[0-9a-f]{4}){3}$/, | |
554 | regexText: 'Does not looks like a valid recovery key', | |
555 | }, | |
556 | { | |
557 | xtype: 'box', | |
558 | reference: 'recoveryInfo', | |
559 | hidden: true, // FIXME: remove this? | |
560 | html: gettext('Note that each recovery code can only be used once!'), | |
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!'), | |
568 | }, | |
569 | ], | |
fbeac4ea | 570 | }, |
f91481ed TL |
571 | ], |
572 | }], | |
fbeac4ea WB |
573 | |
574 | buttons: [ | |
575 | { | |
f91481ed TL |
576 | handler: 'loginTFA', |
577 | reference: 'tfaButton', | |
578 | disabled: true, | |
579 | bind: { | |
2dbc1a9a | 580 | text: '{confirmText}', |
f91481ed TL |
581 | disabled: '{!canConfirm}', |
582 | }, | |
fbeac4ea WB |
583 | }, |
584 | ], | |
585 | }); |