Line data Source code
1 : // SPDX-License-Identifier: LGPL-2.1-or-later
2 : import "./login.css";
3 :
4 3 : function debug(...args) {
5 1 : if (window.debugging === 'all' || window.debugging?.includes('login'))
6 1 : console.debug('login:', ...args);
7 3 : }
8 :
9 20 : (function() {
10 20 : let localStorage;
11 :
12 : /* Some browsers fail localStorage access due to corruption, preventing Cockpit login */
13 20 : try {
14 20 : localStorage = window.localStorage;
15 20 : window.localStorage.removeItem('url-root');
16 20 : window.localStorage.removeItem('standard-login');
17 17 : } catch (ex) {
18 17 : localStorage = window.sessionStorage;
19 17 : console.warn(String(ex));
20 17 : }
21 :
22 : /* Dark mode */
23 20 : const theme = localStorage.getItem('shell:style') || 'auto';
24 17 : if ((window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && theme === "auto") || theme === "dark") {
25 17 : document.documentElement.classList.add('pf-v6-theme-dark');
26 17 : } else {
27 20 : document.documentElement.classList.remove('pf-v6-theme-dark');
28 20 : }
29 :
30 0 : window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
31 0 : if ((event.matches && theme === "auto") || theme === "dark") {
32 0 : document.documentElement.classList.add('pf-v6-theme-dark');
33 0 : } else {
34 0 : document.documentElement.classList.remove('pf-v6-theme-dark');
35 0 : }
36 0 : });
37 :
38 20 : let url_root;
39 17 : const environment = window.environment || { };
40 20 : const oauth = environment.OAuth || null;
41 17 : if (oauth) {
42 17 : if (!oauth.TokenParam)
43 17 : oauth.TokenParam = "access_token";
44 17 : if (!oauth.ErrorParam)
45 17 : oauth.ErrorParam = "error_description";
46 17 : }
47 :
48 20 : const fmt_re = /\$\{([^}]+)\}|\$([a-zA-Z0-9_]+)/g;
49 1 : function format(fmt /* ... */) {
50 1 : const args = Array.prototype.slice.call(arguments, 1);
51 0 : return fmt.replace(fmt_re, function(m, x, y) { return args[x || y] || "" });
52 1 : }
53 :
54 20 : function gettext(key) {
55 17 : if (window.cockpit_po) {
56 17 : const translated = window.cockpit_po[key];
57 17 : if (translated && translated[1])
58 17 : return translated[1];
59 17 : }
60 20 : return key;
61 20 : }
62 :
63 20 : function translate() {
64 20 : const list = document.querySelectorAll("[translate]");
65 20 : for (let i = 0; i < list.length; i++)
66 20 : list[i].textContent = gettext(list[i].textContent);
67 20 : }
68 :
69 20 : const _ = gettext;
70 :
71 20 : let login_path;
72 20 : let application;
73 20 : let org_login_path;
74 20 : let org_application;
75 20 : const qs_re = /[?&]?([^=]+)=([^&]*)/g;
76 20 : let oauth_redirect_to = null;
77 :
78 0 : function QueryParams(qs) {
79 0 : qs = qs.split('+').join(' ');
80 :
81 0 : const params = {};
82 :
83 0 : for (;;) {
84 0 : const tokens = qs_re.exec(qs);
85 0 : if (!tokens)
86 0 : break;
87 0 : params[decodeURIComponent(tokens[1])] = decodeURIComponent(tokens[2]);
88 0 : }
89 0 : return params;
90 0 : }
91 :
92 20 : function id(name) {
93 20 : return document.getElementById(name);
94 20 : }
95 :
96 : // strip off "user@", "*:port", and IPv6 brackets from login target (but keep two :: intact for IPv6)
97 0 : function parseHostname(ssh_target) {
98 0 : return ssh_target
99 0 : .replace(/^.*@/, '')
100 0 : .replace(/(?<!:):[0-9]+$/, '')
101 0 : .replace(/^\[/, '')
102 0 : .replace(/\]$/, '');
103 0 : }
104 :
105 : // Hide an element (or set of elements) based on a boolean
106 : // true: element is hidden, false: element is shown
107 19 : function hideToggle(elements, toggle) {
108 : // If it's a single selector, convert it to an array for the loop
109 19 : if (typeof elements === "string")
110 18 : elements = [elements];
111 :
112 : // >= 1 arguments (of type element or string (for CSS selectors))
113 : // (passed in "arguments" isn't a a true array, so forEach wouldn't always work)
114 19 : for (let i = 0; i < elements.length; i++) {
115 19 : if (typeof elements[i] === "string") {
116 : // Support CSS selectors as a string
117 19 : const els = document.querySelectorAll(elements[i]);
118 :
119 19 : if (els)
120 19 : els.forEach(function(element) {
121 19 : if (element.hidden !== !!toggle)
122 19 : element.hidden = !!toggle;
123 19 : });
124 18 : } else {
125 : // Hide specific elements
126 18 : if (elements[i].hidden !== !!toggle)
127 16 : elements[i].hidden = !!toggle;
128 18 : }
129 19 : }
130 19 : }
131 :
132 : // Show >=1 arguments (element or CSS selector)
133 19 : function show() {
134 19 : hideToggle(arguments, false);
135 19 : }
136 :
137 : // Hide >=1 arguments (element or CSS selector)
138 19 : function hide() {
139 19 : hideToggle(arguments, true);
140 19 : }
141 :
142 0 : function show_captured_stderr(msg) {
143 0 : console.warn("stderr:", msg);
144 :
145 0 : hide("#login-wait-validating");
146 :
147 0 : hide("#login", "#login-details");
148 0 : show("#login-fatal");
149 :
150 0 : id("login-again").onclick = () => { hide('#login-fatal'); show_login() };
151 0 : show("#login-again");
152 :
153 0 : const el = id("login-fatal-message");
154 0 : el.textContent = "";
155 0 : el.appendChild(document.createTextNode(msg));
156 0 : }
157 :
158 0 : function fatal(msg) {
159 0 : console.warn("fatal:", msg);
160 :
161 0 : hide("#login-again", "#login-wait-validating");
162 :
163 0 : if (oauth_redirect_to) {
164 0 : id("login-again").href = oauth_redirect_to;
165 0 : show("#login-again");
166 0 : }
167 :
168 0 : hide("#login", "#login-details");
169 0 : show("#login-fatal");
170 :
171 0 : const el = id("login-fatal-message");
172 0 : el.textContent = "";
173 0 : el.appendChild(document.createTextNode(msg));
174 0 : }
175 :
176 16 : function brand(_id, def) {
177 16 : const elt = id(_id);
178 13 : const style = (elt && window.getComputedStyle) ? window.getComputedStyle(elt, ":before") : null;
179 :
180 16 : if (!style)
181 16 : return;
182 :
183 16 : let content = style.content;
184 16 : if (content && content != "none" && content != "normal") {
185 16 : const len = content.length;
186 13 : if ((content[0] === '"' || content[0] === '\'') &&
187 16 : len > 2 && content[len - 1] === content[0])
188 16 : content = content.substring(1, len - 1);
189 13 : elt.innerHTML = content || def;
190 16 : } else {
191 16 : elt.removeAttribute("class");
192 16 : }
193 16 : }
194 :
195 20 : function requisites() {
196 1 : function showBypass(bypass) {
197 0 : if (bypass) {
198 : // Selectively show and hide elements
199 0 : show("#login", "#login-details", "#login-override");
200 0 : hide("#get-out-link");
201 :
202 : // Reparent login form to the expander
203 0 : id("login-override-content").appendChild(id("login"));
204 :
205 : // Change the state of the button from primary to warning
206 0 : id("login-button").classList.add("pf-m-warning");
207 :
208 : // Render a "helper text" warning above the log in button
209 0 : document.querySelector("#login .login-actions").insertAdjacentHTML(
210 0 : "beforebegin",
211 0 : "<div class='pf-v6-c-helper-text pf-m-warning' id='bypass-warning'>" +
212 0 : _("Cockpit might not render correctly in your browser") +
213 0 : "</div>"
214 0 : );
215 0 : } else {
216 1 : hide("#login", "#login-details", "#login-override");
217 1 : }
218 1 : }
219 :
220 1 : function disableLogin(name, bypass) {
221 1 : if (name === "supports")
222 0 : name = "@supports API";
223 1 : const errorString = format(_("This web browser is too old to run the Web Console (missing $0)"), name);
224 :
225 1 : console.warn(errorString);
226 1 : id("login-error-message").textContent = errorString;
227 1 : show("#unsupported-browser", "#error-group");
228 1 : document.body.classList.add("unsupported-browser");
229 :
230 1 : showBypass(bypass);
231 1 : }
232 :
233 20 : function req(name, obj) {
234 20 : let ret;
235 20 : try {
236 20 : ret = (obj && obj[name]);
237 17 : } catch (ex) {
238 17 : fatal(format(_("The web browser configuration prevents Cockpit from running (inaccessible $0)"), name));
239 17 : throw ex;
240 17 : }
241 18 : if (ret === undefined) {
242 18 : disableLogin(name);
243 18 : return false;
244 18 : }
245 19 : return true;
246 20 : }
247 :
248 19 : function css() {
249 : /*
250 : * Be certain to use parenthesis when checking CSS strings
251 : * as Edge is oddly particular.
252 : *
253 : * Instead of "display: inline", use:
254 : * "(display: inline)"
255 : * or
256 : * "display", "inline"
257 : */
258 19 : const args = [].join.call(arguments, ": ");
259 :
260 17 : if (!window.CSS || !window.CSS.supports.apply(this, arguments)) {
261 17 : disableLogin(args, "bypass");
262 17 : return false;
263 17 : }
264 19 : return true;
265 19 : }
266 :
267 20 : const hard_req = req("WebSocket", window) &&
268 19 : req("XMLHttpRequest", window) &&
269 19 : req("sessionStorage", window) &&
270 19 : req("JSON", window) &&
271 19 : req("defineProperty", Object) &&
272 19 : req("pushState", window.history) &&
273 19 : req("textContent", document) &&
274 19 : req("replaceAll", String.prototype) &&
275 19 : req("finally", Promise.prototype) &&
276 19 : req("supports", window.CSS);
277 :
278 19 : if (hard_req) {
279 19 : css("display", "flex") &&
280 19 : css("display", "grid") &&
281 19 : css("selector(test)") &&
282 19 : css("selector(:is(*):where(*))");
283 19 : return true;
284 17 : } else {
285 18 : return false;
286 18 : }
287 20 : }
288 :
289 : /* Sets values for application, url_root and login_path */
290 20 : function setup_path_globals (path) {
291 20 : const parser = document.createElement('a');
292 : // send_login_html() sets <base> to UrlRoot
293 20 : const base = document.baseURI;
294 :
295 17 : path = path || "/";
296 20 : parser.href = base;
297 18 : if (parser.pathname != "/") {
298 18 : url_root = parser.pathname.replace(/^\/+|\/+$/g, '');
299 : // deprecated: for connecting to cockpit.js < 272
300 18 : localStorage.setItem('url-root', url_root);
301 18 : if (url_root && path.indexOf('/' + url_root) === 0)
302 17 : path = path.replace('/' + url_root, '') || '/';
303 18 : }
304 :
305 17 : if (path.indexOf("/=") === 0) {
306 17 : environment.hostname = path.substring(2).split("/")[0];
307 17 : id("server-field").value = environment.hostname;
308 17 : toggle_options(null, true);
309 17 : path = "/cockpit+" + path.split("/")[1];
310 17 : } else if (path.indexOf("/cockpit/") !== 0 && path.indexOf("/cockpit+") !== 0) {
311 19 : path = "/cockpit";
312 19 : }
313 :
314 20 : application = path.split("/")[1];
315 20 : login_path = "/" + application + "/login";
316 20 : if (url_root)
317 18 : login_path = "/" + url_root + login_path;
318 :
319 20 : org_application = application;
320 20 : org_login_path = login_path;
321 20 : }
322 :
323 4 : function toggle_options(ev, show) {
324 : // On keypress, only accept spacebar (enter acts as a click)
325 4 : if (ev && ev.type === 'keypress' && ev.key !== ' ')
326 4 : return;
327 : // Stop the <a>'s click handler, otherwise it causes a page reload
328 4 : if (ev && ev.type === 'click')
329 4 : ev.preventDefault();
330 :
331 4 : if (show === undefined)
332 4 : show = id("server-group").hidden;
333 :
334 4 : hideToggle("#server-group", !show);
335 :
336 4 : id("option-group").setAttribute("data-state", show);
337 4 : }
338 :
339 0 : function toggle_password(event) {
340 0 : const input = id("login-password-input");
341 :
342 0 : input.setAttribute("type", (input.getAttribute("type") === "password") ? "text" : "password");
343 0 : event.stopPropagation();
344 0 : }
345 :
346 20 : function deal_with_multihost() {
347 : // If we are currently logged in to some machine, but still
348 : // end up on the login page, we are about to load resources
349 : // from two machines into the same browser origin. This needs
350 : // to be allowed explicitly via a configuration setting.
351 :
352 17 : const logged_into = environment.logged_into || [];
353 17 : const cur_machine = logged_into.length > 0 ? logged_into[0] : null;
354 :
355 1 : function redirect_to_current_machine() {
356 1 : if (cur_machine === ".") {
357 1 : login_reload("/");
358 0 : } else {
359 0 : login_reload("/=" + cur_machine);
360 0 : }
361 1 : }
362 :
363 17 : if (cur_machine && !environment.page.allow_multihost)
364 17 : redirect_to_current_machine();
365 20 : }
366 :
367 20 : function boot() {
368 20 : window.onload = null;
369 :
370 20 : translate();
371 17 : if (window.cockpit_po && window.cockpit_po[""]) {
372 17 : document.documentElement.lang = window.cockpit_po[""].language;
373 17 : if (window.cockpit_po[""]["language-direction"])
374 17 : document.documentElement.dir = window.cockpit_po[""]["language-direction"];
375 17 : }
376 :
377 20 : deal_with_multihost();
378 :
379 20 : setup_path_globals(window.location.pathname);
380 :
381 : /* Determine if we are nested or not, and switch styles */
382 20 : if (window.location.pathname.indexOf("/" + url_root + "/cockpit/") === 0 ||
383 19 : window.location.pathname.indexOf("/" + url_root + "/cockpit+") === 0)
384 18 : document.documentElement.setAttribute("class", "inline");
385 :
386 : // Setup title
387 20 : let title = environment.page.title;
388 20 : if (environment.is_cockpit_client)
389 17 : title = _("Login");
390 17 : if (!title || application.indexOf("cockpit+=") === 0)
391 20 : title = environment.hostname;
392 20 : document.title = title;
393 :
394 17 : if (application.indexOf("cockpit+=") === 0) {
395 17 : hide("#brand", "#badge");
396 17 : } else {
397 20 : brand("badge", "");
398 20 : brand("brand", "Cockpit");
399 20 : }
400 :
401 20 : if (!requisites())
402 20 : return;
403 :
404 17 : if (environment.banner) {
405 17 : show("#banner");
406 17 : id("banner-message").textContent = environment.banner.trimEnd();
407 17 : }
408 :
409 19 : id("bypass-browser-check").addEventListener("click", toggle_options);
410 19 : id("bypass-browser-check").addEventListener("keypress", toggle_options);
411 19 : id("show-other-login-options").addEventListener("click", toggle_options);
412 19 : id("show-other-login-options").addEventListener("keypress", toggle_options);
413 0 : id("server-clear").addEventListener("click", function () {
414 0 : const el = id("server-field");
415 0 : el.value = "";
416 0 : el.focus();
417 0 : });
418 :
419 19 : const logout_intent = window.sessionStorage.getItem("logout-intent") == "explicit";
420 19 : if (logout_intent)
421 17 : window.sessionStorage.removeItem("logout-intent");
422 :
423 19 : const logout_reason = window.sessionStorage.getItem("logout-reason");
424 19 : if (logout_reason)
425 17 : window.sessionStorage.removeItem("logout-reason");
426 :
427 : /* Try automatic/kerberos authentication? */
428 17 : if (oauth) {
429 17 : hide("#login-details", "#login");
430 17 : if (logout_intent) {
431 17 : build_oauth_redirect_to();
432 17 : id("login-again").textContent = _("Login again");
433 17 : fatal(_("Logout successful"));
434 17 : } else {
435 17 : oauth_auto_login();
436 17 : }
437 17 : } else if (logout_intent) {
438 17 : show_login(logout_reason);
439 17 : } else if (need_host()) {
440 17 : show_login();
441 17 : } else {
442 19 : standard_auto_login();
443 19 : }
444 20 : }
445 :
446 4 : function standard_auto_login() {
447 4 : const xhr = new XMLHttpRequest();
448 4 : xhr.open("GET", login_path, true);
449 3 : xhr.onreadystatechange = function () {
450 3 : if (xhr.readyState == 4) {
451 1 : if (xhr.status == 200) {
452 1 : run(JSON.parse(xhr.responseText));
453 1 : } else if (xhr.status == 401) {
454 3 : show_login();
455 1 : } else if (xhr.statusText) {
456 1 : fatal(decodeURIComponent(xhr.statusText));
457 1 : } else if (xhr.status === 0) {
458 1 : show_login();
459 1 : } else {
460 1 : fatal(format(_("$0 error"), xhr.status));
461 1 : }
462 3 : }
463 3 : };
464 4 : xhr.send();
465 4 : }
466 :
467 0 : function build_oauth_redirect_to() {
468 0 : const url_parts = window.location.href.split('#', 2);
469 0 : oauth_redirect_to = oauth.URL;
470 0 : if (oauth.URL.indexOf("?") > -1)
471 0 : oauth_redirect_to += "&";
472 : else
473 0 : oauth_redirect_to += "?";
474 0 : oauth_redirect_to += "redirect_uri=" + encodeURIComponent(url_parts[0]);
475 0 : }
476 :
477 0 : function oauth_auto_login() {
478 0 : const parser = document.createElement('a');
479 0 : if (!oauth.URL)
480 0 : return fatal(_("Cockpit authentication is configured incorrectly."));
481 :
482 0 : const query = (!window.location.search && window.location.hash)
483 0 : ? QueryParams(window.location.hash.slice(1))
484 0 : : QueryParams(window.location.search);
485 :
486 : /* Not all providers allow hashes in redirect urls */
487 :
488 0 : build_oauth_redirect_to();
489 :
490 0 : if (query[oauth.TokenParam]) {
491 0 : if (window.sessionStorage.getItem('login-wanted')) {
492 0 : parser.href = window.sessionStorage.getItem('login-wanted');
493 0 : setup_path_globals(parser.pathname);
494 0 : }
495 :
496 0 : const token_val = query[oauth.TokenParam];
497 0 : show("#login-wait-validating");
498 0 : const xhr = new XMLHttpRequest();
499 0 : xhr.open("GET", login_path, true);
500 0 : xhr.setRequestHeader("Authorization", "Bearer " + token_val);
501 0 : xhr.onreadystatechange = function () {
502 0 : if (xhr.readyState == 4) {
503 0 : if (xhr.status == 200) {
504 0 : run(JSON.parse(xhr.responseText));
505 0 : } else {
506 0 : const prompt_data = get_prompt_from_challenge(xhr.getResponseHeader("WWW-Authenticate"), xhr.responseText);
507 0 : if (prompt_data)
508 0 : show_converse(prompt_data);
509 : else
510 0 : fatal(decodeURIComponent(xhr.statusText));
511 0 : }
512 0 : }
513 0 : };
514 0 : xhr.send();
515 0 : } else if (query[oauth.ErrorParam]) {
516 0 : fatal(query[oauth.ErrorParam]);
517 0 : } else {
518 : /* Store url we originally wanted in case we
519 : * had to strip a hash or query params
520 : */
521 0 : window.sessionStorage.setItem('login-wanted',
522 0 : window.location.href);
523 0 : window.location = oauth_redirect_to;
524 0 : }
525 0 : }
526 :
527 3 : function clear_errors() {
528 3 : hide("#error-group");
529 3 : id("login-error-message").textContent = "";
530 3 : }
531 :
532 18 : function clear_info() {
533 18 : hide("#info-group");
534 18 : id("login-info-message").textContent = "";
535 18 : }
536 :
537 3 : function login_failure(title, msg, form) {
538 3 : clear_errors();
539 2 : if (title) {
540 : /* OAuth failures are always fatal */
541 1 : if (oauth) {
542 1 : fatal(title);
543 1 : } else {
544 2 : show_form(form || "login");
545 2 : id("login-error-title").textContent = title;
546 2 : id("login-error-message").textContent = msg;
547 2 : hideToggle("#error-group .pf-v6-c-alert__description", !msg);
548 2 : show("#error-group");
549 2 : }
550 2 : }
551 3 : }
552 :
553 18 : function login_info(msg) {
554 18 : clear_info();
555 16 : if (msg) {
556 16 : id("login-info-message").textContent = msg;
557 16 : show("#info-group");
558 16 : }
559 18 : }
560 :
561 0 : function host_failure(title, msg) {
562 0 : if (!login_machine) {
563 0 : login_failure(msg);
564 0 : } else {
565 0 : clear_errors();
566 0 : id("login-error-title").textContent = title;
567 0 : id("login-error-message").textContent = msg;
568 0 : hideToggle("#error-group .pf-v6-c-alert__description", !msg);
569 0 : show("#error-group");
570 0 : toggle_options(null, true);
571 0 : show_form("login");
572 0 : }
573 0 : }
574 :
575 18 : function login_note(msg) {
576 18 : const el = id("login-note");
577 18 : if (msg) {
578 18 : show(el);
579 18 : el.textContent = msg;
580 16 : } else {
581 16 : el.innerHTML = ' ';
582 16 : }
583 18 : }
584 :
585 5 : function need_host() {
586 5 : return environment.page.require_host &&
587 3 : org_application.indexOf("cockpit+=") === -1;
588 5 : }
589 :
590 2 : function get_recent_hosts() {
591 2 : let hosts = [];
592 2 : try {
593 2 : hosts = JSON.parse(localStorage.getItem("cockpit-client-sessions") || "[]");
594 2 : } catch (e) {
595 2 : console.log("Failed to parse 'cockpit-client-sessions':", e);
596 2 : }
597 :
598 2 : return hosts;
599 2 : }
600 :
601 : // value of #server-field at the time of clicking "Login"
602 20 : let login_machine = null;
603 : /* set by do_hostkey_verification() for a confirmed unknown host fingerprint;
604 : * setup_localstorage() will then write the received full known_hosts entry to the known_hosts
605 : * database for this host */
606 20 : let login_data_host = null;
607 : /* set if our known_host database has a non-matching host key, and we re-attempt the login
608 : * with asking the user for confirmation */
609 20 : let ssh_host_key_change_host = null;
610 :
611 3 : function call_login() {
612 3 : login_failure(null);
613 3 : login_machine = id("server-field").value;
614 3 : login_data_host = null;
615 3 : const user = id("login-user-input").value.trim();
616 1 : if (user === "" && !environment.is_cockpit_client) {
617 1 : login_failure(_("User name cannot be empty"));
618 1 : } else if (need_host() && login_machine === "") {
619 1 : login_failure(_("Please specify the host to connect to"));
620 1 : } else {
621 1 : if (login_machine) {
622 1 : application = "cockpit+=" + login_machine;
623 1 : login_path = org_login_path.replace("/" + org_application + "/", "/" + application + "/");
624 1 : id("brand").style.display = "none";
625 1 : id("badge").style.visibility = "hidden";
626 1 : } else {
627 3 : application = org_application;
628 3 : login_path = org_login_path;
629 3 : brand("badge", "");
630 3 : brand("brand", "Cockpit");
631 3 : }
632 :
633 3 : id("server-name").textContent = login_machine || environment.hostname;
634 3 : id("login-button").removeEventListener("click", call_login);
635 :
636 3 : const password = id("login-password-input").value;
637 :
638 1 : const superuser_key = "superuser:" + user + (login_machine ? ":" + login_machine : "");
639 2 : const superuser = localStorage.getItem(superuser_key) || "none";
640 3 : localStorage.setItem("superuser-key", superuser_key);
641 3 : localStorage.setItem(superuser_key, superuser);
642 :
643 : /* Keep information if login page was used */
644 3 : localStorage.setItem('standard-login', true);
645 :
646 3 : let known_hosts = '';
647 1 : if (login_machine) {
648 1 : if (ssh_host_key_change_host == login_machine) {
649 : /* We came here because logging in ran into invalid-hostkey; so try the next
650 : * round without sending the key. do_hostkey_verification() will notice the
651 : change and show the correct dialog. */
652 1 : debug("call_login(): previous login attempt into", login_machine, "failed due to changed key");
653 1 : } else {
654 : // If we have a known host key, send it to ssh
655 1 : const keys = get_hostkeys(login_machine);
656 1 : if (keys) {
657 1 : debug("call_login(): sending known_host key", keys, "for logging into", login_machine);
658 1 : known_hosts = keys;
659 1 : } else {
660 1 : debug("call_login(): no known_hosts entry for logging into", login_machine);
661 1 : }
662 1 : }
663 1 : }
664 :
665 3 : const headers = {
666 3 : Authorization: "Basic " + window.btoa(utf8(user + ":" + password + '\0' + known_hosts)),
667 3 : "X-Superuser": superuser,
668 3 : };
669 : // allow unknown remote hosts with interactive logins with "Connect to:"
670 3 : if (login_machine)
671 1 : headers["X-SSH-Connect-Unknown-Hosts"] = "yes";
672 :
673 3 : send_login_request("GET", headers, false);
674 3 : }
675 3 : }
676 :
677 2 : function render_recent_hosts() {
678 2 : const hosts = get_recent_hosts();
679 :
680 2 : const list = id("recent-hosts-list");
681 2 : list.innerHTML = "";
682 2 : hosts.forEach(host => {
683 2 : const wrapper = document.createElement("div");
684 2 : wrapper.classList.add("host-line");
685 2 : wrapper.setAttribute("data-host-id", host);
686 :
687 2 : const b1 = document.createElement("button");
688 2 : b1.textContent = host;
689 2 : b1.classList.add("pf-v6-c-button", "pf-m-tertiary", "host-name");
690 0 : b1.addEventListener("click", () => {
691 0 : id("server-field").value = host;
692 0 : call_login();
693 0 : });
694 :
695 2 : const b2 = document.createElement("button");
696 2 : b2.title = _("Remove host");
697 2 : b2.ariaLabel = b2.title;
698 2 : b2.classList.add("host-remove");
699 0 : b2.addEventListener("click", () => {
700 0 : const i = hosts.indexOf(host);
701 0 : hosts.splice(i, 1);
702 0 : localStorage.setItem('cockpit-client-sessions', JSON.stringify(hosts));
703 0 : render_recent_hosts();
704 0 : });
705 :
706 2 : wrapper.append(b1, b2);
707 2 : list.append(wrapper);
708 2 : });
709 2 : hideToggle("#recent-hosts", hosts.length == 0);
710 2 : }
711 :
712 18 : function show_form(form) {
713 18 : const connectable = environment.page.connect;
714 18 : let expanded = id("option-group").getAttribute("data-state");
715 :
716 18 : hide("#login-wait-validating");
717 18 : show("#login");
718 18 : hideToggle("#login-details", environment.is_cockpit_client);
719 18 : hideToggle("#server-field-label", environment.is_cockpit_client);
720 16 : if (environment.is_cockpit_client) {
721 16 : const brand = id("brand");
722 16 : brand.textContent = _("Connect to:");
723 16 : brand.classList.add("text-brand");
724 16 : }
725 :
726 18 : hideToggle(["#user-group", "#password-group"], form != "login" || environment.is_cockpit_client);
727 18 : hideToggle("#conversation-group", form != "conversation");
728 18 : hideToggle("#hostkey-group", form != "hostkey");
729 :
730 16 : id("login-button-text").textContent = (form == "hostkey") ? _("Accept key and log in") : _("Log in");
731 18 : if (form != "login")
732 16 : id("login-password-input").value = '';
733 :
734 16 : if (environment.page.require_host) {
735 16 : hide("#option-group");
736 16 : expanded = true;
737 16 : } else {
738 18 : hideToggle("#option-group", !connectable || form != "login");
739 18 : }
740 :
741 16 : if (!connectable || form != "login") {
742 16 : hide("#server-group");
743 16 : } else {
744 18 : hideToggle("#server-group", !expanded);
745 18 : }
746 :
747 18 : id("login-button").removeAttribute('disabled');
748 18 : id("login-button").removeAttribute('spinning');
749 18 : id("login-button").classList.remove("pf-m-danger");
750 18 : id("login-button").classList.add("pf-m-primary");
751 18 : hide("#get-out-link");
752 :
753 18 : if (form == "login")
754 18 : id("login-button").addEventListener("click", call_login);
755 :
756 16 : if (environment.is_cockpit_client) {
757 16 : render_recent_hosts();
758 16 : document.body.classList.add("cockpit-client");
759 16 : }
760 18 : }
761 :
762 18 : function show_login(message) {
763 : /* Show the login screen */
764 18 : login_info(message);
765 18 : id("server-name").textContent = document.title;
766 18 : login_note(_("Log in with your server user account."));
767 0 : id("login-user-input").addEventListener("keydown", function(e) {
768 0 : login_failure(null);
769 0 : clear_info();
770 0 : if (e.which == 13)
771 0 : id("login-password-input").focus();
772 0 : }, false);
773 :
774 0 : const do_login = function(e) {
775 0 : login_failure(null);
776 0 : if (e.which == 13)
777 0 : call_login();
778 0 : };
779 :
780 18 : id("login-password-input").addEventListener("keydown", do_login);
781 18 : id("login-password-toggle").addEventListener("click", toggle_password);
782 :
783 18 : show_form("login");
784 :
785 18 : if (!environment.is_cockpit_client) {
786 18 : id("login-user-input").focus();
787 16 : } else if (environment.page.require_host) {
788 16 : id("server-field").focus();
789 16 : }
790 18 : }
791 :
792 0 : function get_known_hosts_db() {
793 0 : try {
794 0 : return JSON.parse(localStorage.getItem("known_hosts") || "{ }");
795 0 : } catch (ex) {
796 0 : console.warn("Can't parse known_hosts database in localStorage", ex);
797 0 : return { };
798 0 : }
799 0 : }
800 :
801 0 : function get_hostkeys(host) {
802 0 : return get_known_hosts_db()[parseHostname(host)];
803 0 : }
804 :
805 0 : function set_hostkeys(host, keys) {
806 0 : try {
807 0 : const db = get_known_hosts_db();
808 0 : db[parseHostname(host)] = keys;
809 0 : localStorage.setItem("known_hosts", JSON.stringify(db));
810 0 : } catch (ex) {
811 0 : console.warn("Can't write known_hosts database to localStorage", ex);
812 0 : }
813 0 : }
814 :
815 0 : function do_hostkey_verification(data) {
816 0 : const key = data["host-key"];
817 0 : const key_host = key.split(" ")[0];
818 0 : const key_type = key.split(" ")[1];
819 0 : const db_keys = get_hostkeys(key_host);
820 :
821 0 : if (db_keys) {
822 0 : debug("do_hostkey_verification: received key fingerprint", data.default, "for host", key_host,
823 0 : "does not match key in known_hosts database:", db_keys, "; treating as changed");
824 0 : id("hostkey-title").textContent = format(_("$0 key changed"), login_machine);
825 0 : show("#hostkey-warning-group");
826 0 : id("hostkey-message-1").textContent = "";
827 0 : } else {
828 0 : debug("do_hostkey_verification: received key fingerprint", data.default, "for host", key_host,
829 0 : "not in known_hosts database; treating as new host");
830 0 : id("hostkey-title").textContent = _("New host");
831 0 : hide("#hostkey-warning-group");
832 0 : id("hostkey-message-1").textContent = format(_("You are connecting to $0 for the first time."), login_machine);
833 0 : }
834 :
835 0 : id("hostkey-verify-help-1").textContent = format(_("To verify a fingerprint, run the following on $0 while physically sitting at the machine or through a trusted network:"), login_machine);
836 0 : id("hostkey-verify-help-cmds").textContent = format("ssh-keyscan$0 localhost | ssh-keygen -lf -",
837 0 : key_type ? " -t " + key_type : "");
838 :
839 0 : id("hostkey-fingerprint").textContent = data.default;
840 :
841 0 : if (key_type) {
842 0 : id("hostkey-type").textContent = format("($0)", key_type);
843 0 : show("#hostkey-type");
844 0 : } else {
845 0 : hide("#hostkey-type");
846 0 : }
847 :
848 0 : login_failure("");
849 :
850 0 : function call_converse() {
851 0 : id("login-button").removeEventListener("click", call_converse);
852 0 : login_failure(null, null, "hostkey");
853 : // cockpit-beiboot sends only a placeholder, defer to login-data in setup_localstorage()
854 0 : if (key.endsWith(" login-data")) {
855 0 : login_data_host = key_host;
856 0 : debug("call_converse(): got placeholder host keyfor", login_data_host, ", deferring db update");
857 0 : converse(data.id, data.default);
858 0 : } else {
859 0 : console.error("login: got unexpected host key prompt, expecting login-data placeholder:", key);
860 0 : fatal(_("Internal protocol error"));
861 0 : }
862 0 : }
863 :
864 0 : id("login-button").addEventListener("click", call_converse);
865 :
866 0 : show_form("hostkey");
867 0 : show("#get-out-link");
868 :
869 0 : if (db_keys) {
870 0 : id("login-button").classList.add("pf-m-danger");
871 0 : id("login-button").classList.remove("pf-m-primary");
872 0 : }
873 0 : }
874 :
875 0 : function show_converse(prompt_data) {
876 0 : if (prompt_data["host-key"]) {
877 0 : do_hostkey_verification(prompt_data);
878 0 : return;
879 0 : }
880 :
881 0 : const type = prompt_data.echo ? "text" : "password";
882 0 : id("conversation-prompt").textContent = prompt_data.prompt;
883 :
884 0 : const em = id("conversation-message");
885 0 : const msg = prompt_data.error || prompt_data.message;
886 0 : if (msg) {
887 0 : em.textContent = msg;
888 0 : show(em);
889 0 : } else {
890 0 : hide(em);
891 0 : }
892 :
893 0 : const ei = id("conversation-input");
894 0 : ei.value = "";
895 0 : if (prompt_data.default)
896 0 : ei.value = prompt_data.default;
897 0 : ei.setAttribute('type', type);
898 :
899 0 : login_failure("");
900 :
901 0 : function call_converse() {
902 0 : id("conversation-input").removeEventListener("keydown", key_down);
903 0 : id("login-button").removeEventListener("click", call_converse);
904 0 : login_failure(null, null, "conversation");
905 0 : converse(prompt_data.id, id("conversation-input").value);
906 0 : }
907 :
908 0 : function key_down(e) {
909 0 : login_failure(null, null, "conversation");
910 0 : if (e.which == 13) {
911 0 : call_converse();
912 0 : }
913 0 : }
914 :
915 0 : id("conversation-input").addEventListener("keydown", key_down);
916 0 : id("login-button").addEventListener("click", call_converse);
917 0 : show_form("conversation");
918 0 : ei.focus();
919 0 : }
920 :
921 3 : function utf8(str) {
922 3 : return window.unescape(encodeURIComponent(str));
923 3 : }
924 :
925 0 : function get_prompt_from_challenge (header, body) {
926 0 : if (!header)
927 0 : return null;
928 :
929 0 : const parts = header.split(' ');
930 0 : if (parts[0].toLowerCase() !== 'x-conversation' || parts.length !== 3)
931 0 : return null;
932 :
933 0 : const id = parts[1];
934 0 : let prompt;
935 0 : try {
936 0 : prompt = window.atob(parts[2]);
937 0 : } catch (err) {
938 0 : console.error("Invalid prompt data", err);
939 0 : return null;
940 0 : }
941 :
942 0 : let resp;
943 0 : try {
944 0 : resp = JSON.parse(body);
945 0 : } catch (err) {
946 0 : console.log("Got invalid JSON response for prompt data", err);
947 0 : resp = {};
948 0 : }
949 :
950 0 : resp.id = id;
951 0 : resp.prompt = prompt;
952 0 : return resp;
953 0 : }
954 :
955 3 : function send_login_request(method, headers, is_conversation) {
956 3 : debug("send_login_request():", method, "headers:", JSON.stringify(headers));
957 3 : id("login-button").setAttribute('disabled', "true");
958 3 : id("login-button").setAttribute('spinning', "true");
959 3 : const xhr = new XMLHttpRequest();
960 3 : xhr.open(method, login_path, true);
961 :
962 3 : for (const k in headers)
963 3 : xhr.setRequestHeader(k, headers[k]);
964 :
965 3 : xhr.onreadystatechange = function () {
966 3 : if (xhr.readyState != 4) {
967 3 : return;
968 3 : }
969 2 : if (xhr.status == 200) {
970 2 : const resp = JSON.parse(xhr.responseText);
971 2 : run(resp);
972 1 : } else if (xhr.status == 401) {
973 1 : debug("send_login_request():", method, "got 401, status:", xhr.statusText, "; response:", xhr.responseText);
974 1 : const challenge = xhr.getResponseHeader("WWW-Authenticate");
975 1 : if (challenge && challenge.toLowerCase().indexOf("x-conversation") === 0) {
976 1 : const prompt_data = get_prompt_from_challenge(challenge, xhr.responseText);
977 1 : if (prompt_data)
978 1 : show_converse(prompt_data);
979 : else
980 1 : fatal(_("Internal error: Invalid challenge header"));
981 1 : } else {
982 1 : console.log(xhr.statusText);
983 : /* did the user confirm a changed SSH host key? If so, update database */
984 1 : if (ssh_host_key_change_host) {
985 1 : try {
986 1 : const keys = JSON.parse(xhr.responseText)["known-hosts"];
987 1 : if (keys) {
988 1 : debug("send_login_request(): got updated known-hosts for changed host keys of", ssh_host_key_change_host, ":", keys);
989 1 : set_hostkeys(ssh_host_key_change_host, keys);
990 1 : ssh_host_key_change_host = null;
991 1 : } else {
992 1 : debug("send_login_request():", ssh_host_key_change_host, "changed key, but did not get an updated key from response");
993 1 : }
994 1 : } catch (ex) {
995 1 : console.error("Failed to parse response text as JSON:", xhr.responseText, ":", JSON.stringify(ex));
996 1 : }
997 1 : }
998 :
999 1 : if (xhr.statusText.startsWith("captured-stderr:")) {
1000 1 : show_captured_stderr(decodeURIComponent(xhr.statusText.replace(/^captured-stderr:/, '')));
1001 1 : } else if (xhr.statusText.indexOf("authentication-not-supported") > -1) {
1002 1 : const user = id("login-user-input").value.trim();
1003 1 : fatal(format(_("The server refused to authenticate '$0' using password authentication, and no other supported authentication methods are available."), user));
1004 1 : } else if (xhr.statusText.indexOf("terminated") > -1) {
1005 1 : login_failure(_("Authentication failed"), _("Server closed connection"));
1006 1 : } else if (xhr.statusText.indexOf("no-host") > -1) {
1007 1 : host_failure(_("Unable to connect to that address"));
1008 1 : } else if (xhr.statusText.indexOf("unknown-hostkey") > -1) {
1009 1 : host_failure(_("Refusing to connect"), _("Hostkey is unknown"));
1010 1 : } else if (xhr.statusText.indexOf("unknown-host") > -1) {
1011 1 : host_failure(_("Refusing to connect"), _("Host is unknown"));
1012 1 : } else if (xhr.statusText.indexOf("invalid-hostkey") > -1) {
1013 : /* ssh/ferny/beiboot immediately fail in this case, it's not a conversation;
1014 : * ask the user for confirmation and try again */
1015 1 : if (ssh_host_key_change_host === null) {
1016 1 : debug("send_login_request(): invalid-hostkey, trying again to let the user confirm");
1017 1 : ssh_host_key_change_host = login_machine;
1018 1 : call_login();
1019 1 : } else {
1020 : // but only once, to avoid loops
1021 1 : debug("send_login_request(): invalid-hostkey, and already retried, giving up");
1022 1 : host_failure(_("Refusing to connect"), _("Hostkey does not match"));
1023 1 : }
1024 1 : } else if (is_conversation) {
1025 1 : login_failure(_("Authentication failed"));
1026 1 : } else {
1027 1 : login_failure(_("Authentication failed"), _("Wrong user name or password"));
1028 1 : }
1029 1 : }
1030 1 : } else if (xhr.status == 403) {
1031 2 : const status = decodeURIComponent(xhr.statusText).trim();
1032 1 : login_failure(_("Permission denied"), status === "Permission denied" ? "" : status);
1033 1 : } else if (xhr.status == 500 && xhr.statusText.indexOf("no-cockpit") > -1) {
1034 1 : const message = format(
1035 1 : _("Install the cockpit-system package (and optionally other cockpit packages) on $0 to enable web console access."),
1036 1 : login_machine || "localhost");
1037 :
1038 1 : login_failure(_("Packageless session unavailable"), message);
1039 1 : } else if (xhr.status == 500 && xhr.statusText.indexOf("unsupported-shell") > -1) {
1040 1 : login_failure(_("Authentication failed"),
1041 1 : _("Unsupported shell. Check the journal for details."));
1042 1 : } else if (xhr.statusText) {
1043 1 : fatal(decodeURIComponent(xhr.statusText));
1044 1 : } else {
1045 1 : fatal(format(_("$0 error"), xhr.status));
1046 1 : }
1047 3 : };
1048 3 : xhr.send();
1049 3 : }
1050 :
1051 0 : function converse(id, msg) {
1052 0 : const headers = {
1053 0 : Authorization: "X-Conversation " + id + " " + window.btoa(utf8(msg))
1054 0 : };
1055 0 : send_login_request("GET", headers, true);
1056 0 : }
1057 :
1058 2 : function login_reload (wanted) {
1059 : // Force a reload if not triggered below
1060 : // because only the hash part of the url
1061 : // changed
1062 1 : let timer = window.setTimeout(function() {
1063 1 : timer = null;
1064 1 : window.location.reload(true);
1065 1 : }, 100);
1066 :
1067 1 : if (wanted && wanted != window.location.href)
1068 1 : window.location = wanted;
1069 :
1070 : // cancel forced reload if we are reloading
1071 1 : window.onbeforeunload = function() {
1072 1 : if (timer)
1073 0 : window.clearTimeout(timer);
1074 1 : timer = null;
1075 1 : };
1076 2 : }
1077 :
1078 1 : function clear_storage (storage, prefix, full) {
1079 1 : let i = 0;
1080 1 : while (i < storage.length) {
1081 1 : const k = storage.key(i);
1082 0 : if (full && k.indexOf("cockpit") !== 0)
1083 0 : storage.removeItem(k);
1084 1 : else if (k.indexOf(prefix) === 0)
1085 0 : storage.removeItem(k);
1086 : else
1087 1 : i++;
1088 1 : }
1089 1 : }
1090 :
1091 1 : function setup_localstorage (response) {
1092 : /* Clear anything not prefixed with
1093 : * different application from sessionStorage
1094 : */
1095 1 : clear_storage(window.sessionStorage, application, true);
1096 :
1097 : /* Clear anything prefixed with our application
1098 : * and login-data, but not other non-application values.
1099 : */
1100 1 : localStorage.removeItem('login-data');
1101 1 : clear_storage(localStorage, application, false);
1102 :
1103 0 : if (response && response["login-data"]) {
1104 0 : const str = JSON.stringify(response["login-data"]);
1105 : /* login-data is tied to the auth cookie, since
1106 : * cookies are available after the page
1107 : * session ends login-data should be too.
1108 : */
1109 0 : localStorage.setItem(application + 'login-data', str);
1110 : /* Backwards compatibility for packages that aren't application prefixed */
1111 0 : localStorage.setItem('login-data', str);
1112 :
1113 : /* When confirming a host key with cockpit-beiboot, login-data contains the known_hosts pubkey;
1114 : * update our database */
1115 0 : if (login_data_host) {
1116 0 : const hostkey = response["login-data"]["known-hosts"];
1117 0 : if (hostkey) {
1118 0 : debug("setup_localstorage(): updating known_hosts database for deferred host key for", login_data_host, ":", hostkey);
1119 0 : set_hostkeys(login_data_host, hostkey);
1120 0 : } else {
1121 0 : console.error("login.js internal error: setup_localstorage() received a pending login-data host, but login-data does not contain known-hosts");
1122 0 : }
1123 0 : }
1124 0 : }
1125 :
1126 : /* URL Root is set by cockpit ws and shouldn't be prefixed
1127 : * by application
1128 : * deprecated: for connecting to cockpit.js < 272
1129 : */
1130 1 : if (url_root)
1131 1 : localStorage.setItem('url-root', url_root);
1132 :
1133 1 : const ca_cert_url = environment.CACertUrl;
1134 1 : if (ca_cert_url)
1135 1 : window.sessionStorage.setItem('CACertUrl', ca_cert_url);
1136 1 : }
1137 :
1138 1 : function run(response) {
1139 1 : let wanted = window.sessionStorage.getItem('login-wanted');
1140 1 : const machine = id("server-field").value;
1141 :
1142 : /* When using cockpit client remember all the addresses being used */
1143 0 : if (machine && environment.is_cockpit_client) {
1144 0 : const hosts = get_recent_hosts();
1145 0 : if (hosts.indexOf(machine) < 0) {
1146 0 : hosts.push(machine);
1147 0 : localStorage.setItem('cockpit-client-sessions', JSON.stringify(hosts));
1148 0 : }
1149 0 : }
1150 :
1151 0 : if (machine && application != org_application) {
1152 0 : wanted = "/=" + machine;
1153 0 : if (url_root)
1154 0 : wanted = "/" + url_root + wanted;
1155 0 : }
1156 :
1157 : /* clean up sessionStorage. clear anything that isn't prefixed
1158 : * with an application and anything prefixed with our application.
1159 : */
1160 1 : clear_storage(window.sessionStorage, application, false);
1161 :
1162 1 : setup_localstorage(response);
1163 1 : login_reload(wanted);
1164 1 : }
1165 :
1166 20 : window.onload = boot;
1167 20 : })();
|