]>
Commit | Line | Data |
---|---|---|
64176447 WB |
1 | /*global QRCode*/ |
2 | Ext.define('Proxmox.window.AddTotp', { | |
3 | extend: 'Proxmox.window.Edit', | |
4 | alias: 'widget.pmxAddTotp', | |
5 | mixins: ['Proxmox.Mixin.CBind'], | |
6 | ||
7 | onlineHelp: 'user_mgmt', | |
8 | ||
9 | modal: true, | |
10 | resizable: false, | |
11 | title: gettext('Add a TOTP login factor'), | |
12 | width: 512, | |
13 | layout: { | |
14 | type: 'vbox', | |
15 | align: 'stretch', | |
16 | }, | |
17 | ||
18 | isAdd: true, | |
19 | userid: undefined, | |
20 | tfa_id: undefined, | |
d6f0eee9 | 21 | issuerName: `Proxmox - ${document?.location?.hostname || 'unknown'}`, |
64176447 WB |
22 | fixedUser: false, |
23 | ||
24 | updateQrCode: function() { | |
25 | let me = this; | |
26 | let values = me.lookup('totp_form').getValues(); | |
27 | let algorithm = values.algorithm; | |
28 | if (!algorithm) { | |
29 | algorithm = 'SHA1'; | |
30 | } | |
31 | ||
32 | let otpuri = | |
33 | 'otpauth://totp/' + | |
34 | encodeURIComponent(values.issuer) + | |
35 | ':' + | |
36 | encodeURIComponent(values.userid) + | |
37 | '?secret=' + values.secret + | |
38 | '&period=' + values.step + | |
39 | '&digits=' + values.digits + | |
40 | '&algorithm=' + algorithm + | |
41 | '&issuer=' + encodeURIComponent(values.issuer); | |
42 | ||
43 | me.getController().getViewModel().set('otpuri', otpuri); | |
44 | me.qrcode.makeCode(otpuri); | |
45 | me.lookup('challenge').setVisible(true); | |
46 | me.down('#qrbox').setVisible(true); | |
47 | }, | |
48 | ||
49 | viewModel: { | |
50 | data: { | |
51 | valid: false, | |
52 | secret: '', | |
53 | otpuri: '', | |
54 | userid: null, | |
55 | }, | |
56 | ||
57 | formulas: { | |
58 | secretEmpty: function(get) { | |
59 | return get('secret').length === 0; | |
60 | }, | |
61 | }, | |
62 | }, | |
63 | ||
64 | controller: { | |
65 | xclass: 'Ext.app.ViewController', | |
66 | control: { | |
67 | 'field[qrupdate=true]': { | |
68 | change: function() { | |
69 | this.getView().updateQrCode(); | |
70 | }, | |
71 | }, | |
72 | 'field': { | |
73 | validitychange: function(field, valid) { | |
74 | let me = this; | |
75 | let viewModel = me.getViewModel(); | |
76 | let form = me.lookup('totp_form'); | |
77 | let challenge = me.lookup('challenge'); | |
78 | let password = me.lookup('password'); | |
79 | viewModel.set('valid', form.isValid() && challenge.isValid() && password.isValid()); | |
80 | }, | |
81 | }, | |
82 | '#': { | |
83 | show: function() { | |
84 | let me = this; | |
85 | let view = me.getView(); | |
86 | ||
87 | view.qrdiv = document.createElement('div'); | |
88 | view.qrcode = new QRCode(view.qrdiv, { | |
89 | width: 256, | |
90 | height: 256, | |
91 | correctLevel: QRCode.CorrectLevel.M, | |
92 | }); | |
93 | view.down('#qrbox').getEl().appendChild(view.qrdiv); | |
94 | ||
95 | view.getController().randomizeSecret(); | |
96 | }, | |
97 | }, | |
98 | }, | |
99 | ||
100 | randomizeSecret: function() { | |
101 | let me = this; | |
102 | let rnd = new Uint8Array(32); | |
103 | window.crypto.getRandomValues(rnd); | |
104 | let data = ''; | |
105 | rnd.forEach(function(b) { | |
106 | // secret must be base32, so just use the first 5 bits | |
107 | b = b & 0x1f; | |
108 | if (b < 26) { | |
109 | // A..Z | |
110 | data += String.fromCharCode(b + 0x41); | |
111 | } else { | |
112 | // 2..7 | |
113 | data += String.fromCharCode(b-26 + 0x32); | |
114 | } | |
115 | }); | |
116 | me.getViewModel().set('secret', data); | |
117 | }, | |
118 | }, | |
119 | ||
120 | items: [ | |
121 | { | |
122 | xtype: 'form', | |
123 | layout: 'anchor', | |
124 | border: false, | |
125 | reference: 'totp_form', | |
126 | fieldDefaults: { | |
127 | anchor: '100%', | |
128 | }, | |
129 | items: [ | |
130 | { | |
131 | xtype: 'pmxDisplayEditField', | |
132 | name: 'userid', | |
133 | cbind: { | |
134 | editable: (get) => get('isAdd') && !get('fixedUser'), | |
135 | value: () => Proxmox.UserName, | |
136 | }, | |
137 | fieldLabel: gettext('User'), | |
138 | editConfig: { | |
139 | xtype: 'pmxUserSelector', | |
140 | allowBlank: false, | |
141 | }, | |
142 | renderer: Ext.String.htmlEncode, | |
143 | listeners: { | |
144 | change: function(field, newValue, oldValue) { | |
145 | let vm = this.up('window').getViewModel(); | |
146 | vm.set('userid', newValue); | |
147 | }, | |
148 | }, | |
149 | qrupdate: true, | |
150 | }, | |
151 | { | |
152 | xtype: 'textfield', | |
153 | fieldLabel: gettext('Description'), | |
154 | emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'), | |
155 | allowBlank: false, | |
156 | name: 'description', | |
157 | maxLength: 256, | |
158 | }, | |
159 | { | |
160 | layout: 'hbox', | |
161 | border: false, | |
162 | padding: '0 0 5 0', | |
163 | items: [ | |
164 | { | |
165 | xtype: 'textfield', | |
166 | fieldLabel: gettext('Secret'), | |
167 | emptyText: gettext('Unchanged'), | |
168 | name: 'secret', | |
169 | reference: 'tfa_secret', | |
170 | regex: /^[A-Z2-7=]+$/, | |
171 | regexText: 'Must be base32 [A-Z2-7=]', | |
172 | maskRe: /[A-Z2-7=]/, | |
173 | qrupdate: true, | |
174 | bind: { | |
175 | value: "{secret}", | |
176 | }, | |
177 | flex: 4, | |
178 | padding: '0 5 0 0', | |
179 | }, | |
180 | { | |
181 | xtype: 'button', | |
182 | text: gettext('Randomize'), | |
183 | reference: 'randomize_button', | |
184 | handler: 'randomizeSecret', | |
185 | flex: 1, | |
186 | }, | |
187 | ], | |
188 | }, | |
189 | { | |
190 | xtype: 'numberfield', | |
191 | fieldLabel: gettext('Time period'), | |
192 | name: 'step', | |
193 | // Google Authenticator ignores this and generates bogus data | |
194 | hidden: true, | |
195 | value: 30, | |
196 | minValue: 10, | |
197 | qrupdate: true, | |
198 | }, | |
199 | { | |
200 | xtype: 'numberfield', | |
201 | fieldLabel: gettext('Digits'), | |
202 | name: 'digits', | |
203 | value: 6, | |
204 | // Google Authenticator ignores this and generates bogus data | |
205 | hidden: true, | |
206 | minValue: 6, | |
207 | maxValue: 8, | |
208 | qrupdate: true, | |
209 | }, | |
210 | { | |
211 | xtype: 'textfield', | |
212 | fieldLabel: gettext('Issuer Name'), | |
213 | name: 'issuer', | |
214 | cbind: { | |
215 | value: '{issuerName}', | |
216 | }, | |
217 | qrupdate: true, | |
218 | }, | |
219 | { | |
220 | xtype: 'box', | |
221 | itemId: 'qrbox', | |
222 | visible: false, // will be enabled when generating a qr code | |
223 | bind: { | |
224 | visible: '{!secretEmpty}', | |
225 | }, | |
226 | style: { | |
745aeaac | 227 | margin: '5px auto', |
64176447 WB |
228 | padding: '5px', |
229 | width: '266px', | |
230 | height: '266px', | |
745aeaac | 231 | 'background-color': 'white', |
64176447 WB |
232 | }, |
233 | }, | |
234 | { | |
235 | xtype: 'textfield', | |
236 | fieldLabel: gettext('Verify Code'), | |
237 | allowBlank: false, | |
238 | reference: 'challenge', | |
239 | name: 'challenge', | |
240 | bind: { | |
241 | disabled: '{!showTOTPVerifiction}', | |
242 | visible: '{showTOTPVerifiction}', | |
243 | }, | |
244 | emptyText: gettext('Scan QR code in a TOTP app and enter an auth. code here'), | |
245 | }, | |
246 | { | |
247 | xtype: 'textfield', | |
248 | name: 'password', | |
249 | reference: 'password', | |
250 | fieldLabel: gettext('Verify Password'), | |
251 | inputType: 'password', | |
252 | minLength: 5, | |
253 | allowBlank: false, | |
254 | validateBlank: true, | |
255 | cbind: { | |
256 | hidden: () => Proxmox.UserName === 'root@pam', | |
257 | disabled: () => Proxmox.UserName === 'root@pam', | |
258 | emptyText: () => | |
259 | Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName), | |
260 | }, | |
261 | }, | |
262 | ], | |
263 | }, | |
264 | ], | |
265 | ||
266 | initComponent: function() { | |
267 | let me = this; | |
268 | me.url = '/api2/extjs/access/tfa/'; | |
269 | me.method = 'POST'; | |
270 | me.callParent(); | |
271 | }, | |
272 | ||
273 | getValues: function(dirtyOnly) { | |
274 | let me = this; | |
275 | let viewmodel = me.getController().getViewModel(); | |
276 | ||
277 | let values = me.callParent(arguments); | |
278 | ||
279 | let uid = encodeURIComponent(values.userid); | |
280 | me.url = `/api2/extjs/access/tfa/${uid}`; | |
281 | delete values.userid; | |
282 | ||
283 | let data = { | |
284 | description: values.description, | |
285 | type: "totp", | |
286 | totp: viewmodel.get('otpuri'), | |
287 | value: values.challenge, | |
288 | }; | |
289 | ||
290 | if (values.password) { | |
291 | data.password = values.password; | |
292 | } | |
293 | ||
294 | return data; | |
295 | }, | |
296 | }); |