diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index c23c8975..c799a958 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -17,6 +17,41 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.11" @@ -316,6 +351,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" +dependencies = [ + "axum 0.7.5", + "axum-core 0.4.3", + "bytes", + "cookie", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.73" @@ -650,7 +708,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "aes-gcm", + "base64 0.22.1", "percent-encoding", + "rand", + "subtle", "time", "version_check", ] @@ -790,6 +852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -814,6 +877,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.4.4" @@ -1246,6 +1318,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.29.0" @@ -2031,6 +2113,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum 0.7.5", + "axum-extra", "bincode", "csv", "dashmap", @@ -2406,6 +2489,12 @@ version = "11.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.64" @@ -2580,6 +2669,18 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.6.0" @@ -4056,6 +4157,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/src/rust/lqos_config/src/authentication.rs b/src/rust/lqos_config/src/authentication.rs index b85ccdca..9856cfca 100644 --- a/src/rust/lqos_config/src/authentication.rs +++ b/src/rust/lqos_config/src/authentication.rs @@ -66,6 +66,10 @@ impl WebUsers { Ok(filename) } + pub fn is_empty(&self) -> bool { + self.users.is_empty() + } + fn save_to_disk(&self) -> Result<(), AuthenticationError> { let path = Self::path()?; let new_contents = toml_edit::ser::to_string(&self); diff --git a/src/rust/lqosd/Cargo.toml b/src/rust/lqosd/Cargo.toml index d448414e..cb5efcee 100644 --- a/src/rust/lqosd/Cargo.toml +++ b/src/rust/lqosd/Cargo.toml @@ -39,6 +39,7 @@ ip_network = "0" zerocopy = {version = "0.6.1", features = [ "simd" ] } fxhash = "0.2.1" axum = { version = "0.7.5", features = ["ws"] } +axum-extra = { version = "0.9.3", features = ["cookie", "cookie-private"] } tower-http = { version = "0.5.2", features = ["fs"] } strum = { version = "0.26.3", features = ["derive"] } diff --git a/src/rust/lqosd/src/node_manager.rs b/src/rust/lqosd/src/node_manager.rs index 414b6716..d99141c2 100644 --- a/src/rust/lqosd/src/node_manager.rs +++ b/src/rust/lqosd/src/node_manager.rs @@ -3,5 +3,6 @@ mod static_pages; mod template; mod ws; mod local_api; +mod auth; pub use run::spawn_webserver; \ No newline at end of file diff --git a/src/rust/lqosd/src/node_manager/auth.rs b/src/rust/lqosd/src/node_manager/auth.rs new file mode 100644 index 00000000..d16db588 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/auth.rs @@ -0,0 +1,137 @@ +//! Provides authentication for the Node Manager. +//! This is designed to be broadly compatible with the original +//! cookie-based system, but now uses an Axum layer to be largely +//! invisible. + +use axum::http::StatusCode; +use axum::Json; +use axum::response::{Html, Response}; +use axum_extra::extract::cookie::Cookie; +use axum_extra::extract::CookieJar; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; +use lqos_config::{UserRole, WebUsers}; + +const COOKIE_PATH: &str = "User-Token"; + +static WEB_USERS : Lazy>> = Lazy::new(|| Mutex::new(None)); + +pub async fn get_username(jar: &CookieJar) -> String { + let lock = WEB_USERS.lock().await; + if let Some(users) = &*lock { + if let Some(token) = jar.get(COOKIE_PATH) { + return users.get_username(token.value()); + } + } + + return "Anonymous".to_string(); +} + +#[derive(Copy, Clone)] +pub enum LoginResult { + Admin, + ReadOnly, + Denied, +} + +async fn check_login(jar: &CookieJar, users: &WebUsers) -> LoginResult { + if let Some(token) = jar.get(COOKIE_PATH) { + // Validate the token + return match users.get_role_from_token(token.value()).unwrap() { + UserRole::ReadOnly => LoginResult::ReadOnly, + UserRole::Admin => LoginResult::Admin, + } + } + LoginResult::Denied +} + +/// Checks an incoming request for a User-Token cookie. If found, +/// it validates the request against the web users file. If the +/// web users file isn't found, it redirects to 'first run'. If +/// it is found, the token is checked (and redirected to login if +/// it isn't good). Finally, the user's role is injected into +/// the middleware. +pub async fn auth_layer( + jar: CookieJar, + mut req: axum::extract::Request, + next: axum::middleware::Next, +) -> Result> { + const BOUNCE: &str = ""; + const FIRST_RUN: &str = ""; + + let mut lock = WEB_USERS.lock().await; + if lock.is_none() { + // No lock - let's see if there's a file to use? + if WebUsers::does_users_file_exist().unwrap() { + // It exists - we load it + let users = WebUsers::load_or_create().unwrap(); + *lock = Some(users); + } else { + // No users file - redirect to first run + return Err(Html(FIRST_RUN)); + } + } + + if let Some(users) = &*lock { + let login_result = check_login(&jar, users).await; + return match login_result { + LoginResult::Admin | LoginResult::ReadOnly => { + req.extensions_mut().insert(login_result); + Ok(next.run(req).await) + } + LoginResult::Denied => Err(Html(BOUNCE)), + }; + } + Err(Html(BOUNCE)) +} + +#[derive(Serialize, Deserialize)] +pub struct LoginAttempt { + pub username: String, + pub password: String, +} + +pub async fn try_login( + jar: CookieJar, + Json(login) : Json, +) -> Result<(CookieJar, StatusCode), StatusCode> { + let users = WEB_USERS.lock().await; + if let Some(users) = &*users { + return match users.login(&login.username, &login.password) { + Ok(token) => { + Ok((jar.add(Cookie::new(COOKIE_PATH, token)), StatusCode::OK)) + } + Err(..) => { + if users.do_we_allow_anonymous() { + Ok((jar, StatusCode::OK)) + } else { + Err(StatusCode::UNAUTHORIZED) + } + } + } + } + Err(StatusCode::UNAUTHORIZED) +} + +#[derive(Serialize, Deserialize)] +pub struct FirstUser { + username: String, + password: String, + allow_anonymous: bool +} + +pub async fn first_user( + jar: CookieJar, + Json(new_user) : Json +) -> (CookieJar, StatusCode) { + let mut users = WebUsers::load_or_create().unwrap(); + users.allow_anonymous(new_user.allow_anonymous).unwrap(); + let token = users.add_or_update_user(&new_user.username, &new_user.password, UserRole::Admin).unwrap(); + let mut lock = WEB_USERS.lock().await; + *lock = Some(users); + ( + jar.add(Cookie::new(COOKIE_PATH, token)), + StatusCode::OK + ) +} \ No newline at end of file diff --git a/src/rust/lqosd/src/node_manager/js_build/esbuild.sh b/src/rust/lqosd/src/node_manager/js_build/esbuild.sh index 26723574..0e9638a3 100755 --- a/src/rust/lqosd/src/node_manager/js_build/esbuild.sh +++ b/src/rust/lqosd/src/node_manager/js_build/esbuild.sh @@ -1,6 +1,6 @@ #!/bin/bash set -e -scripts=( index.js template.js ) +scripts=( index.js template.js login.js first-run.js ) for script in "${scripts[@]}" do echo "Building {$script}" diff --git a/src/rust/lqosd/src/node_manager/js_build/src/first-run.js b/src/rust/lqosd/src/node_manager/js_build/src/first-run.js new file mode 100644 index 00000000..c4a024cb --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/first-run.js @@ -0,0 +1,31 @@ +$("#btnCreateUser").on('click', () => { + let username = $("#username").val(); + let password = $("#password").val(); + let anon = document.getElementById("allowAnonymous").checked; + if (username === "") { + alert("You must enter a username"); + return; + } + if (password === "") { + alert("You must enter a password"); + return; + } + + let login = { + allow_anonymous: anon, + username: username, + password: password + } + $.ajax({ + type: "POST", + url: "/firstLogin", + data: JSON.stringify(login), + contentType: 'application/json', + success: () => { + window.location.href = "/index.html"; + }, + error: () => { + alert("Something went wrong"); + } + }) +}); \ No newline at end of file diff --git a/src/rust/lqosd/src/node_manager/js_build/src/login.js b/src/rust/lqosd/src/node_manager/js_build/src/login.js new file mode 100644 index 00000000..691f0630 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/login.js @@ -0,0 +1,30 @@ +$("#btnLogin").on('click', () => { + let username = $("#username").val(); + let password = $("#password").val(); + if (username === "") { + alert("You must enter a username"); + return; + } + if (password === "") { + alert("You must enter a password"); + return; + } + + let login = { + username: username, + password: password + } + + $.ajax({ + type: "POST", + url: "/doLogin", + data: JSON.stringify(login), + contentType: 'application/json', + success: () => { + window.location.href = "/index.html"; + }, + error: () => { + alert("Login Incorrect"); + } + }) +}); \ No newline at end of file diff --git a/src/rust/lqosd/src/node_manager/js_build/src/template.js b/src/rust/lqosd/src/node_manager/js_build/src/template.js index 5a1b1187..296f2548 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/template.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/template.js @@ -45,5 +45,21 @@ function getDeviceCounts() { }) } +function initLogout() { + $("#btnLogout").on('click', () => { + console.log("Logout"); + const cookies = document.cookie.split(";"); + + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i]; + const eqPos = cookie.indexOf("="); + const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie; + document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT"; + } + window.location.reload(); + }); +} + +initLogout(); initDayNightMode(); getDeviceCounts(); \ No newline at end of file diff --git a/src/rust/lqosd/src/node_manager/run.rs b/src/rust/lqosd/src/node_manager/run.rs index 5a4823ff..7afbcd4a 100644 --- a/src/rust/lqosd/src/node_manager/run.rs +++ b/src/rust/lqosd/src/node_manager/run.rs @@ -4,10 +4,10 @@ use log::info; use tokio::net::TcpListener; use anyhow::{bail, Result}; use axum::response::Redirect; -use axum::routing::get; +use axum::routing::{get, post}; use tower_http::services::ServeDir; use lqos_config::load_config; -use crate::node_manager::{static_pages::{static_routes, vendor_route}, ws::websocket_router}; +use crate::node_manager::{auth, static_pages::{static_routes, vendor_route}, ws::websocket_router}; use crate::node_manager::local_api::local_api; /// Launches the Axum webserver to take over node manager duties. @@ -26,10 +26,12 @@ pub async fn spawn_webserver() -> Result<()> { if !static_path.exists() { bail!("Static path not found for webserver (vin/static2/"); } - + // Construct the router from parts let router = Router::new() .route("/", get(redirect_to_index)) + .route("/doLogin", post(auth::try_login)) + .route("/firstLogin", post(auth::first_user)) .nest("/websocket/", websocket_router()) .nest("/vendor", vendor_route()?) // Serve /vendor as purely static .nest("/", static_routes()?) diff --git a/src/rust/lqosd/src/node_manager/static2/first-run.html b/src/rust/lqosd/src/node_manager/static2/first-run.html new file mode 100644 index 00000000..40564e81 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/static2/first-run.html @@ -0,0 +1,70 @@ + + + + + + LibreQoS Node Manager + + + + + + + + + +
+
+
+
+
+
+
First Login
+

+ No lqusers.toml file was found. This is probably the first time you've run + the LibreQoS web system. If it isn't, then please check permissions on that file and + use the "bin/lqusers" command to verify that your system is working. +

+ +

Let's create a new user, and set some parameters:

+ + + + + + + + + + + +
+ + +
+ Your Username + + +
Your password
+ Create User Account +
+
+
+
+
+
+ + + +
+ © Copyright 2022-2024, LibreQoE LLC +
+ + + + \ No newline at end of file diff --git a/src/rust/lqosd/src/node_manager/static2/login.html b/src/rust/lqosd/src/node_manager/static2/login.html new file mode 100644 index 00000000..56664475 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/static2/login.html @@ -0,0 +1,51 @@ + + + + + + LibreQoS Node Manager + + + + + + + + + +
+
+
+
+
+
+
Login
+

Please enter a username and password to access LibreQoS.

+

You can control access locally with bin/lqusers from the console.

+ + + + + + + + + +
Username
Password
+ Login +
+
+
+
+
+
+ + + +
+ © Copyright 2022-2024, LibreQoE LLC +
+ + + + \ No newline at end of file diff --git a/src/rust/lqosd/src/node_manager/static2/template.html b/src/rust/lqosd/src/node_manager/static2/template.html index 44fb5a7c..31977e99 100644 --- a/src/rust/lqosd/src/node_manager/static2/template.html +++ b/src/rust/lqosd/src/node_manager/static2/template.html @@ -102,9 +102,15 @@
diff --git a/src/rust/lqosd/src/node_manager/static_pages.rs b/src/rust/lqosd/src/node_manager/static_pages.rs index 7f5be859..8e501a0d 100644 --- a/src/rust/lqosd/src/node_manager/static_pages.rs +++ b/src/rust/lqosd/src/node_manager/static_pages.rs @@ -3,6 +3,7 @@ use axum::Router; use tower_http::services::{ServeDir, ServeFile}; use lqos_config::load_config; use anyhow::{bail, Result}; +use crate::node_manager::auth::auth_layer; use crate::node_manager::template::apply_templates; pub(super) fn vendor_route() -> Result { @@ -46,6 +47,7 @@ pub(super) fn static_routes() -> Result { router = router.route_service(&format!("/{page}"), ServeFile::new(path)); } router = router + .route_layer(axum::middleware::from_fn(auth_layer)) .route_layer(axum::middleware::from_fn(apply_templates)); Ok(router) diff --git a/src/rust/lqosd/src/node_manager/template.rs b/src/rust/lqosd/src/node_manager/template.rs index f5d79b0e..88dfc6d2 100644 --- a/src/rust/lqosd/src/node_manager/template.rs +++ b/src/rust/lqosd/src/node_manager/template.rs @@ -6,9 +6,12 @@ use axum::body::{Body, to_bytes}; use axum::http::{HeaderValue, Request, Response, StatusCode}; use axum::middleware::Next; use axum::response::IntoResponse; +use axum_extra::extract::CookieJar; use lqos_config::load_config; +use crate::node_manager::auth::get_username; pub async fn apply_templates( + jar: CookieJar, req: Request, next: Next, ) -> Result { @@ -27,13 +30,18 @@ pub async fn apply_templates( std::fs::read_to_string(path).unwrap() }; + // Update the displayed username + let username = get_username(&jar).await; + let template_text = template_text.replace("%%USERNAME%%", &username); + let res = next.run(req).await; if apply_template { let (mut res_parts, res_body) = res.into_parts(); let bytes = to_bytes(res_body, 1_000_000).await.unwrap(); let byte_string = String::from_utf8_lossy(&bytes).to_string(); - let byte_string = template_text.replace("%%BODY%%", &byte_string); + let byte_string = template_text + .replace("%%BODY%%", &byte_string); if let Some(length) = res_parts.headers.get_mut("content-length") { *length = HeaderValue::from(byte_string.len()); }