Line data Source code
1 : // SPDX-License-Identifier: LGPL-2.1-or-later
2 : import "./login.css";
3 :
4 5 : function debug(...args) {
5 3 : if (window.debugging === 'all' || window.debugging?.includes('login'))
6 3 : console.debug('login:', ...args);
7 5 : }
8 :
9 38 : (function() {
10 38 : let localStorage;
11 :
12 : /* Some browsers fail localStorage access due to corruption, preventing Cockpit login */
13 38 : try {
14 38 : localStorage = window.localStorage;
15 38 : window.localStorage.removeItem('url-root');
16 38 : window.localStorage.removeItem('standard-login');
17 34 : } catch (ex) {
18 34 : localStorage = window.sessionStorage;
19 34 : console.warn(String(ex));
20 34 : }
21 :
22 : /* Dark mode */
23 38 : const theme = localStorage.getItem('shell:style') || 'auto';
24 34 : if ((window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && theme === "auto") || theme === "dark") {
25 34 : document.documentElement.classList.add('pf-v6-theme-dark');
26 34 : } else {
27 38 : document.documentElement.classList.remove('pf-v6-theme-dark');
28 38 : }
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 38 : let url_root;
39 34 : const environment = window.environment || { };
40 38 : const oauth = environment.OAuth || null;
41 34 : if (oauth) {
42 34 : if (!oauth.TokenParam)
43 34 : oauth.TokenParam = "access_token";
44 34 : if (!oauth.ErrorParam)
45 34 : oauth.ErrorParam = "error_description";
46 34 : }
47 :
48 38 : const fmt_re = /\$\{([^}]+)\}|\$([a-zA-Z0-9_]+)/g;
49 2 : function format(fmt /* ... */) {
50 2 : const args = Array.prototype.slice.call(arguments, 1);
51 1 : return fmt.replace(fmt_re, function(m, x, y) { return args[x || y] || "" });
52 2 : }
53 :
54 38 : function gettext(key) {
55 34 : if (window.cockpit_po) {
56 34 : const translated = window.cockpit_po[key];
57 34 : if (translated && translated[1])
58 34 : return translated[1];
59 34 : }
60 38 : return key;
61 38 : }
62 :
63 38 : function translate() {
64 38 : const list = document.querySelectorAll("[translate]");
65 38 : for (let i = 0; i < list.length; i++)
66 38 : list[i].textContent = gettext(list[i].textContent);
67 38 : }
68 :
69 38 : const _ = gettext;
70 :
71 38 : let login_path;
72 38 : let application;
73 38 : let org_login_path;
74 38 : let org_application;
75 38 : const qs_re = /[?&]?([^=]+)=([^&]*)/g;
76 38 : 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 38 : function id(name) {
93 38 : return document.getElementById(name);
94 38 : }
95 :
96 : // strip off "user@", "*:port", and IPv6 brackets from login target (but keep two :: intact for IPv6)
97 1 : function parseHostname(ssh_target) {
98 1 : return ssh_target
99 1 : .replace(/^.*@/, '')
100 1 : .replace(/(?<!:):[0-9]+$/, '')
101 1 : .replace(/^\[/, '')
102 1 : .replace(/\]$/, '');
103 1 : }
104 :
105 : // Hide an element (or set of elements) based on a boolean
106 : // true: element is hidden, false: element is shown
107 37 : function hideToggle(elements, toggle) {
108 : // If it's a single selector, convert it to an array for the loop
109 37 : if (typeof elements === "string")
110 36 : 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 37 : for (let i = 0; i < elements.length; i++) {
115 37 : if (typeof elements[i] === "string") {
116 : // Support CSS selectors as a string
117 37 : const els = document.querySelectorAll(elements[i]);
118 :
119 37 : if (els)
120 37 : els.forEach(function(element) {
121 37 : if (element.hidden !== !!toggle)
122 37 : element.hidden = !!toggle;
123 37 : });
124 36 : } else {
125 : // Hide specific elements
126 36 : if (elements[i].hidden !== !!toggle)
127 33 : elements[i].hidden = !!toggle;
128 36 : }
129 37 : }
130 37 : }
131 :
132 : // Show >=1 arguments (element or CSS selector)
133 37 : function show() {
134 37 : hideToggle(arguments, false);
135 37 : }
136 :
137 : // Hide >=1 arguments (element or CSS selector)
138 37 : function hide() {
139 37 : hideToggle(arguments, true);
140 37 : }
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 34 : function brand(_id, def) {
177 34 : const elt = id(_id);
178 29 : const style = (elt && window.getComputedStyle) ? window.getComputedStyle(elt, ":before") : null;
179 :
180 34 : if (!style)
181 34 : return;
182 :
183 34 : let content = style.content;
184 34 : if (content && content != "none" && content != "normal") {
185 34 : const len = content.length;
186 29 : if ((content[0] === '"' || content[0] === '\'') &&
187 34 : len > 2 && content[len - 1] === content[0])
188 34 : content = content.substring(1, len - 1);
189 29 : elt.innerHTML = content || def;
190 34 : } else {
191 34 : elt.removeAttribute("class");
192 34 : }
193 34 : }
194 :
195 38 : 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 38 : function req(name, obj) {
234 38 : let ret;
235 38 : try {
236 38 : ret = (obj && obj[name]);
237 34 : } catch (ex) {
238 34 : fatal(format(_("The web browser configuration prevents Cockpit from running (inaccessible $0)"), name));
239 34 : throw ex;
240 34 : }
241 35 : if (ret === undefined) {
242 35 : disableLogin(name);
243 35 : return false;
244 35 : }
245 37 : return true;
246 38 : }
247 :
248 37 : 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 37 : const args = [].join.call(arguments, ": ");
259 :
260 34 : if (!window.CSS || !window.CSS.supports.apply(this, arguments)) {
261 34 : disableLogin(args, "bypass");
262 34 : return false;
263 34 : }
264 37 : return true;
265 37 : }
266 :
267 38 : const hard_req = req("WebSocket", window) &&
268 37 : req("XMLHttpRequest", window) &&
269 37 : req("sessionStorage", window) &&
270 37 : req("JSON", window) &&
271 37 : req("defineProperty", Object) &&
272 37 : req("pushState", window.history) &&
273 37 : req("textContent", document) &&
274 37 : req("replaceAll", String.prototype) &&
275 37 : req("finally", Promise.prototype) &&
276 37 : req("supports", window.CSS);
277 :
278 37 : if (hard_req) {
279 37 : css("display", "flex") &&
280 37 : css("display", "grid") &&
281 37 : css("selector(test)") &&
282 37 : css("selector(:is(*):where(*))");
283 37 : return true;
284 34 : } else {
285 35 : return false;
286 35 : }
287 38 : }
288 :
289 : /* Sets values for application, url_root and login_path */
290 38 : function setup_path_globals (path) {
291 38 : const parser = document.createElement('a');
292 : // send_login_html() sets <base> to UrlRoot
293 38 : const base = document.baseURI;
294 :
295 34 : path = path || "/";
296 38 : parser.href = base;
297 35 : if (parser.pathname != "/") {
298 35 : url_root = parser.pathname.replace(/^\/+|\/+$/g, '');
299 : // deprecated: for connecting to cockpit.js < 272
300 35 : localStorage.setItem('url-root', url_root);
301 35 : if (url_root && path.indexOf('/' + url_root) === 0)
302 34 : path = path.replace('/' + url_root, '') || '/';
303 35 : }
304 :
305 34 : if (path.indexOf("/=") === 0) {
306 34 : environment.hostname = path.substring(2).split("/")[0];
307 34 : id("server-field").value = environment.hostname;
308 34 : toggle_options(null, true);
309 34 : path = "/cockpit+" + path.split("/")[1];
310 34 : } else if (path.indexOf("/cockpit/") !== 0 && path.indexOf("/cockpit+") !== 0) {
311 37 : path = "/cockpit";
312 37 : }
313 :
314 38 : application = path.split("/")[1];
315 38 : login_path = "/" + application + "/login";
316 38 : if (url_root)
317 35 : login_path = "/" + url_root + login_path;
318 :
319 38 : org_application = application;
320 38 : org_login_path = login_path;
321 38 : }
322 :
323 5 : function toggle_options(ev, show) {
324 : // On keypress, only accept spacebar (enter acts as a click)
325 5 : if (ev && ev.type === 'keypress' && ev.key !== ' ')
326 5 : return;
327 : // Stop the <a>'s click handler, otherwise it causes a page reload
328 5 : if (ev && ev.type === 'click')
329 5 : ev.preventDefault();
330 :
331 5 : if (show === undefined)
332 5 : show = id("server-group").hidden;
333 :
334 5 : hideToggle("#server-group", !show);
335 :
336 5 : id("option-group").setAttribute("data-state", show);
337 5 : }
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 38 : 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 34 : const logged_into = environment.logged_into || [];
353 34 : 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 34 : if (cur_machine && !environment.page.allow_multihost)
364 34 : redirect_to_current_machine();
365 38 : }
366 :
367 38 : function boot() {
368 38 : window.onload = null;
369 :
370 38 : translate();
371 34 : if (window.cockpit_po && window.cockpit_po[""]) {
372 34 : document.documentElement.lang = window.cockpit_po[""].language;
373 34 : if (window.cockpit_po[""]["language-direction"])
374 34 : document.documentElement.dir = window.cockpit_po[""]["language-direction"];
375 34 : }
376 :
377 38 : deal_with_multihost();
378 :
379 38 : setup_path_globals(window.location.pathname);
380 :
381 : /* Determine if we are nested or not, and switch styles */
382 38 : if (window.location.pathname.indexOf("/" + url_root + "/cockpit/") === 0 ||
383 37 : window.location.pathname.indexOf("/" + url_root + "/cockpit+") === 0)
384 35 : document.documentElement.setAttribute("class", "inline");
385 :
386 : // Setup title
387 38 : let title = environment.page.title;
388 38 : if (environment.is_cockpit_client)
389 34 : title = _("Login");
390 34 : if (!title || application.indexOf("cockpit+=") === 0)
391 38 : title = environment.hostname;
392 38 : document.title = title;
393 :
394 34 : if (application.indexOf("cockpit+=") === 0) {
395 34 : hide("#brand", "#badge");
396 34 : } else {
397 38 : brand("badge", "");
398 38 : brand("brand", "Cockpit");
399 38 : }
400 :
401 38 : if (!requisites())
402 38 : return;
403 :
404 34 : if (environment.banner) {
405 34 : show("#banner");
406 34 : id("banner-message").textContent = environment.banner.trimEnd();
407 34 : }
408 :
409 37 : id("bypass-browser-check").addEventListener("click", toggle_options);
410 37 : id("bypass-browser-check").addEventListener("keypress", toggle_options);
411 37 : id("show-other-login-options").addEventListener("click", toggle_options);
412 37 : 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 37 : const logout_intent = window.sessionStorage.getItem("logout-intent") == "explicit";
420 37 : if (logout_intent)
421 35 : window.sessionStorage.removeItem("logout-intent");
422 :
423 37 : const logout_reason = window.sessionStorage.getItem("logout-reason");
424 37 : if (logout_reason)
425 34 : window.sessionStorage.removeItem("logout-reason");
426 :
427 : /* Try automatic/kerberos authentication? */
428 34 : if (oauth) {
429 34 : hide("#login-details", "#login");
430 34 : if (logout_intent) {
431 34 : build_oauth_redirect_to();
432 34 : id("login-again").textContent = _("Login again");
433 34 : fatal(_("Logout successful"));
434 34 : } else {
435 34 : oauth_auto_login();
436 34 : }
437 34 : } else if (logout_intent) {
438 35 : show_login(logout_reason);
439 34 : } else if (need_host()) {
440 34 : show_login();
441 34 : } else {
442 36 : standard_auto_login();
443 36 : }
444 38 : }
445 :
446 6 : function standard_auto_login() {
447 6 : const xhr = new XMLHttpRequest();
448 6 : xhr.open("GET", login_path, true);
449 5 : xhr.onreadystatechange = function () {
450 5 : if (xhr.readyState == 4) {
451 3 : if (xhr.status == 200) {
452 3 : run(JSON.parse(xhr.responseText));
453 3 : } else if (xhr.status == 401) {
454 5 : show_login();
455 3 : } else if (xhr.statusText) {
456 3 : fatal(decodeURIComponent(xhr.statusText));
457 3 : } else if (xhr.status === 0) {
458 3 : show_login();
459 3 : } else {
460 3 : fatal(format(_("$0 error"), xhr.status));
461 3 : }
462 5 : }
463 5 : };
464 6 : xhr.send();
465 6 : }
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 5 : function clear_errors() {
528 5 : hide("#error-group");
529 5 : id("login-error-message").textContent = "";
530 5 : }
531 :
532 36 : function clear_info() {
533 36 : hide("#info-group");
534 36 : id("login-info-message").textContent = "";
535 36 : }
536 :
537 5 : function login_failure(title, msg, form) {
538 5 : clear_errors();
539 4 : if (title) {
540 : /* OAuth failures are always fatal */
541 3 : if (oauth) {
542 3 : fatal(title);
543 3 : } else {
544 4 : show_form(form || "login");
545 4 : id("login-error-title").textContent = title;
546 4 : id("login-error-message").textContent = msg;
547 4 : hideToggle("#error-group .pf-v6-c-alert__description", !msg);
548 4 : show("#error-group");
549 4 : }
550 4 : }
551 5 : }
552 :
553 36 : function login_info(msg) {
554 36 : clear_info();
555 33 : if (msg) {
556 33 : id("login-info-message").textContent = msg;
557 33 : show("#info-group");
558 33 : }
559 36 : }
560 :
561 1 : function host_failure(title, msg) {
562 0 : if (!login_machine) {
563 0 : login_failure(msg);
564 0 : } else {
565 1 : clear_errors();
566 1 : id("login-error-title").textContent = title;
567 1 : id("login-error-message").textContent = msg;
568 1 : hideToggle("#error-group .pf-v6-c-alert__description", !msg);
569 1 : show("#error-group");
570 1 : toggle_options(null, true);
571 1 : show_form("login");
572 1 : }
573 1 : }
574 :
575 36 : function login_note(msg) {
576 36 : const el = id("login-note");
577 36 : if (msg) {
578 36 : show(el);
579 36 : el.textContent = msg;
580 33 : } else {
581 33 : el.innerHTML = ' ';
582 33 : }
583 36 : }
584 :
585 7 : function need_host() {
586 7 : return environment.page.require_host &&
587 5 : org_application.indexOf("cockpit+=") === -1;
588 7 : }
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 38 : 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 38 : 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 38 : let ssh_host_key_change_host = null;
610 :
611 5 : function call_login() {
612 5 : login_failure(null);
613 5 : login_machine = id("server-field").value;
614 5 : login_data_host = null;
615 5 : const user = id("login-user-input").value.trim();
616 3 : if (user === "" && !environment.is_cockpit_client) {
617 3 : login_failure(_("User name cannot be empty"));
618 3 : } else if (need_host() && login_machine === "") {
619 3 : login_failure(_("Please specify the host to connect to"));
620 3 : } else {
621 3 : if (login_machine) {
622 3 : application = "cockpit+=" + login_machine;
623 3 : login_path = org_login_path.replace("/" + org_application + "/", "/" + application + "/");
624 3 : id("brand").style.display = "none";
625 3 : id("badge").style.visibility = "hidden";
626 3 : } else {
627 5 : application = org_application;
628 5 : login_path = org_login_path;
629 5 : brand("badge", "");
630 5 : brand("brand", "Cockpit");
631 5 : }
632 :
633 5 : id("server-name").textContent = login_machine || environment.hostname;
634 5 : id("login-button").removeEventListener("click", call_login);
635 :
636 5 : const password = id("login-password-input").value;
637 :
638 3 : const superuser_key = "superuser:" + user + (login_machine ? ":" + login_machine : "");
639 4 : const superuser = localStorage.getItem(superuser_key) || "none";
640 5 : localStorage.setItem("superuser-key", superuser_key);
641 5 : localStorage.setItem(superuser_key, superuser);
642 :
643 : /* Keep information if login page was used */
644 5 : localStorage.setItem('standard-login', true);
645 :
646 5 : let known_hosts = '';
647 3 : if (login_machine) {
648 3 : 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 3 : debug("call_login(): previous login attempt into", login_machine, "failed due to changed key");
653 3 : } else {
654 : // If we have a known host key, send it to ssh
655 3 : const keys = get_hostkeys(login_machine);
656 3 : if (keys) {
657 3 : debug("call_login(): sending known_host key", keys, "for logging into", login_machine);
658 3 : known_hosts = keys;
659 3 : } else {
660 3 : debug("call_login(): no known_hosts entry for logging into", login_machine);
661 3 : }
662 3 : }
663 3 : }
664 :
665 5 : const headers = {
666 5 : Authorization: "Basic " + window.btoa(utf8(user + ":" + password + '\0' + known_hosts)),
667 5 : "X-Superuser": superuser,
668 5 : };
669 : // allow unknown remote hosts with interactive logins with "Connect to:"
670 5 : if (login_machine)
671 3 : headers["X-SSH-Connect-Unknown-Hosts"] = "yes";
672 :
673 5 : send_login_request("GET", headers, false);
674 5 : }
675 5 : }
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 36 : function show_form(form) {
713 36 : const connectable = environment.page.connect;
714 36 : let expanded = id("option-group").getAttribute("data-state");
715 :
716 36 : hide("#login-wait-validating");
717 36 : show("#login");
718 36 : hideToggle("#login-details", environment.is_cockpit_client);
719 36 : hideToggle("#server-field-label", environment.is_cockpit_client);
720 33 : if (environment.is_cockpit_client) {
721 33 : const brand = id("brand");
722 33 : brand.textContent = _("Connect to:");
723 33 : brand.classList.add("text-brand");
724 33 : }
725 :
726 36 : hideToggle(["#user-group", "#password-group"], form != "login" || environment.is_cockpit_client);
727 36 : hideToggle("#conversation-group", form != "conversation");
728 36 : hideToggle("#hostkey-group", form != "hostkey");
729 :
730 33 : id("login-button-text").textContent = (form == "hostkey") ? _("Accept key and log in") : _("Log in");
731 36 : if (form != "login")
732 33 : id("login-password-input").value = '';
733 :
734 33 : if (environment.page.require_host) {
735 33 : hide("#option-group");
736 33 : expanded = true;
737 33 : } else {
738 36 : hideToggle("#option-group", !connectable || form != "login");
739 36 : }
740 :
741 33 : if (!connectable || form != "login") {
742 33 : hide("#server-group");
743 33 : } else {
744 36 : hideToggle("#server-group", !expanded);
745 36 : }
746 :
747 36 : id("login-button").removeAttribute('disabled');
748 36 : id("login-button").removeAttribute('spinning');
749 36 : id("login-button").classList.remove("pf-m-danger");
750 36 : id("login-button").classList.add("pf-m-primary");
751 36 : hide("#get-out-link");
752 :
753 36 : if (form == "login")
754 36 : id("login-button").addEventListener("click", call_login);
755 :
756 33 : if (environment.is_cockpit_client) {
757 33 : render_recent_hosts();
758 33 : document.body.classList.add("cockpit-client");
759 33 : }
760 36 : }
761 :
762 36 : function show_login(message) {
763 : /* Show the login screen */
764 36 : login_info(message);
765 36 : id("server-name").textContent = document.title;
766 36 : login_note(_("Log in with your server user account."));
767 1 : id("login-user-input").addEventListener("keydown", function(e) {
768 1 : login_failure(null);
769 1 : clear_info();
770 1 : if (e.which == 13)
771 1 : id("login-password-input").focus();
772 1 : }, false);
773 :
774 1 : const do_login = function(e) {
775 1 : login_failure(null);
776 1 : if (e.which == 13)
777 1 : call_login();
778 1 : };
779 :
780 36 : id("login-password-input").addEventListener("keydown", do_login);
781 36 : id("login-password-toggle").addEventListener("click", toggle_password);
782 :
783 36 : show_form("login");
784 :
785 36 : if (!environment.is_cockpit_client) {
786 36 : id("login-user-input").focus();
787 33 : } else if (environment.page.require_host) {
788 33 : id("server-field").focus();
789 33 : }
790 36 : }
791 :
792 1 : function get_known_hosts_db() {
793 1 : try {
794 1 : return JSON.parse(localStorage.getItem("known_hosts") || "{ }");
795 1 : } catch (ex) {
796 1 : console.warn("Can't parse known_hosts database in localStorage", ex);
797 1 : return { };
798 1 : }
799 1 : }
800 :
801 1 : function get_hostkeys(host) {
802 1 : return get_known_hosts_db()[parseHostname(host)];
803 1 : }
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 5 : function utf8(str) {
922 5 : return window.unescape(encodeURIComponent(str));
923 5 : }
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 5 : function send_login_request(method, headers, is_conversation) {
956 5 : debug("send_login_request():", method, "headers:", JSON.stringify(headers));
957 5 : id("login-button").setAttribute('disabled', "true");
958 5 : id("login-button").setAttribute('spinning', "true");
959 5 : const xhr = new XMLHttpRequest();
960 5 : xhr.open(method, login_path, true);
961 :
962 5 : for (const k in headers)
963 5 : xhr.setRequestHeader(k, headers[k]);
964 :
965 5 : xhr.onreadystatechange = function () {
966 5 : if (xhr.readyState != 4) {
967 5 : return;
968 5 : }
969 4 : if (xhr.status == 200) {
970 4 : const resp = JSON.parse(xhr.responseText);
971 4 : run(resp);
972 3 : } else if (xhr.status == 401) {
973 3 : debug("send_login_request():", method, "got 401, status:", xhr.statusText, "; response:", xhr.responseText);
974 3 : const challenge = xhr.getResponseHeader("WWW-Authenticate");
975 3 : if (challenge && challenge.toLowerCase().indexOf("x-conversation") === 0) {
976 3 : const prompt_data = get_prompt_from_challenge(challenge, xhr.responseText);
977 3 : if (prompt_data)
978 3 : show_converse(prompt_data);
979 : else
980 3 : fatal(_("Internal error: Invalid challenge header"));
981 3 : } else {
982 3 : console.log(xhr.statusText);
983 : /* did the user confirm a changed SSH host key? If so, update database */
984 3 : if (ssh_host_key_change_host) {
985 3 : try {
986 3 : const keys = JSON.parse(xhr.responseText)["known-hosts"];
987 3 : if (keys) {
988 3 : debug("send_login_request(): got updated known-hosts for changed host keys of", ssh_host_key_change_host, ":", keys);
989 3 : set_hostkeys(ssh_host_key_change_host, keys);
990 3 : ssh_host_key_change_host = null;
991 3 : } else {
992 3 : debug("send_login_request():", ssh_host_key_change_host, "changed key, but did not get an updated key from response");
993 3 : }
994 3 : } catch (ex) {
995 3 : console.error("Failed to parse response text as JSON:", xhr.responseText, ":", JSON.stringify(ex));
996 3 : }
997 3 : }
998 :
999 3 : if (xhr.statusText.startsWith("captured-stderr:")) {
1000 3 : show_captured_stderr(decodeURIComponent(xhr.statusText.replace(/^captured-stderr:/, '')));
1001 3 : } else if (xhr.statusText.indexOf("authentication-not-supported") > -1) {
1002 3 : const user = id("login-user-input").value.trim();
1003 3 : fatal(format(_("The server refused to authenticate '$0' using password authentication, and no other supported authentication methods are available."), user));
1004 3 : } else if (xhr.statusText.indexOf("terminated") > -1) {
1005 3 : login_failure(_("Authentication failed"), _("Server closed connection"));
1006 3 : } else if (xhr.statusText.indexOf("no-host") > -1) {
1007 3 : host_failure(_("Unable to connect to that address"));
1008 3 : } else if (xhr.statusText.indexOf("unknown-hostkey") > -1) {
1009 3 : host_failure(_("Refusing to connect"), _("Hostkey is unknown"));
1010 3 : } else if (xhr.statusText.indexOf("unknown-host") > -1) {
1011 3 : host_failure(_("Refusing to connect"), _("Host is unknown"));
1012 3 : } 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 3 : if (ssh_host_key_change_host === null) {
1016 3 : debug("send_login_request(): invalid-hostkey, trying again to let the user confirm");
1017 3 : ssh_host_key_change_host = login_machine;
1018 3 : call_login();
1019 3 : } else {
1020 : // but only once, to avoid loops
1021 3 : debug("send_login_request(): invalid-hostkey, and already retried, giving up");
1022 3 : host_failure(_("Refusing to connect"), _("Hostkey does not match"));
1023 3 : }
1024 3 : } else if (is_conversation) {
1025 3 : login_failure(_("Authentication failed"));
1026 3 : } else {
1027 3 : login_failure(_("Authentication failed"), _("Wrong user name or password"));
1028 3 : }
1029 3 : }
1030 3 : } else if (xhr.status == 403) {
1031 4 : const status = decodeURIComponent(xhr.statusText).trim();
1032 3 : login_failure(_("Permission denied"), status === "Permission denied" ? "" : status);
1033 3 : } else if (xhr.status == 500 && xhr.statusText.indexOf("no-cockpit") > -1) {
1034 3 : const message = format(
1035 3 : _("Install the cockpit-system package (and optionally other cockpit packages) on $0 to enable web console access."),
1036 3 : login_machine || "localhost");
1037 :
1038 3 : login_failure(_("Packageless session unavailable"), message);
1039 3 : } else if (xhr.status == 500 && xhr.statusText.indexOf("unsupported-shell") > -1) {
1040 3 : login_failure(_("Authentication failed"),
1041 3 : _("Unsupported shell. Check the journal for details."));
1042 3 : } else if (xhr.statusText) {
1043 3 : fatal(decodeURIComponent(xhr.statusText));
1044 3 : } else {
1045 3 : fatal(format(_("$0 error"), xhr.status));
1046 3 : }
1047 5 : };
1048 5 : xhr.send();
1049 5 : }
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 38 : window.onload = boot;
1167 38 : })();
|