Fully ported login system, including anonymous users, first user creation, login, logout and user display.

This commit is contained in:
Herbert Wolverson 2024-07-03 11:13:52 -05:00
parent 26d5250472
commit a3ce3384ee
15 changed files with 478 additions and 8 deletions

111
src/rust/Cargo.lock generated
View File

@ -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"

View File

@ -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);

View File

@ -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"] }

View File

@ -3,5 +3,6 @@ mod static_pages;
mod template;
mod ws;
mod local_api;
mod auth;
pub use run::spawn_webserver;

View File

@ -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<Mutex<Option<WebUsers>>> = 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<Response, Html<&'static str>> {
const BOUNCE: &str = "<html><body><script>window.location.href = 'login.html';</script></body></html>";
const FIRST_RUN: &str = "<html><body><script>window.location.href = 'first-run.html';</script></body></html>";
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<LoginAttempt>,
) -> 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<FirstUser>
) -> (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
)
}

View File

@ -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}"

View File

@ -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");
}
})
});

View File

@ -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");
}
})
});

View File

@ -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();

View File

@ -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.
@ -30,6 +30,8 @@ pub async fn spawn_webserver() -> Result<()> {
// 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()?)

View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LibreQoS Node Manager</title>
<link href="vendor/bootstrap.min.css" rel="stylesheet">
<script src="vendor/jquery-3.7.1.min.js"></script>
<script src="vendor/echarts.min.js"></script>
<script src="vendor/echarts-gl.min.js"></script>
<script src="vendor/4c979e6ebb.js"></script>
<link href="./node_manager.css" rel="stylesheet">
</head>
<body>
<div id="container" class="pad4">
<div class="row">
<div class="col-sm-4"></div>
<div class="col-sm-4">
<div class="card bg-light">
<div class="card-body">
<h5 class="card-title">First Login</h5>
<p>
No <em>lqusers.toml</em> 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.
</p>
<p class="alert alert-warning" role="alert">
This site will use a cookie to store your identification. If that's not ok,
please don't use the site.
</p>
<p>Let's create a new user, and set some parameters:</p>
<table class="table">
<tr>
<td colspan="2">
<input class="form-check-input" type="checkbox" value="" id="allowAnonymous">
<label class="form-check-label" for="allowAnonymous">
Allow anonymous users to view (but not change) settings.
</label>
</td>
</tr><tr>
<td>
Your Username
</td>
<td>
<input type="text" id="username" />
</td>
</tr>
<tr>
<td>Your password</td>
<td><input type="password" id="password" /></td>
</tr>
</table>
<a class="btn btn-primary" id="btnCreateUser">Create User Account</a>
</div>
</div>
</div>
<div class="col-sm-4"></div>
</div>
</div>
<script src="first-run.js"></script>
<footer class="justify-content-center">
&copy; Copyright 2022-2024, LibreQoE LLC
</footer>
<script src="vendor/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LibreQoS Node Manager</title>
<link href="vendor/bootstrap.min.css" rel="stylesheet">
<script src="vendor/jquery-3.7.1.min.js"></script>
<script src="vendor/echarts.min.js"></script>
<script src="vendor/echarts-gl.min.js"></script>
<script src="vendor/4c979e6ebb.js"></script>
<link href="./node_manager.css" rel="stylesheet">
</head>
<body>
<div id="container" class="pad4">
<div class="row">
<div class="col-sm-4"></div>
<div class="col-sm-4">
<div class="card bg-light">
<div class="card-body">
<h5 class="card-title">Login</h5>
<p>Please enter a username and password to access LibreQoS.</p>
<p>You can control access locally with <em>bin/lqusers</em> from the console.</p>
<table class="table">
<tr>
<td>Username</td>
<td><input type="text" id="username" /></td>
</tr>
<tr>
<td>Password</td>
<td><input type="password" id="password" /></td>
</tr>
</table>
<a class="btn btn-primary" id="btnLogin">Login</a>
</div>
</div>
</div>
<div class="col-sm-4"></div>
</div>
</div>
<script src="login.js"></script>
<footer class="justify-content-center">
&copy; Copyright 2022-2024, LibreQoE LLC
</footer>
<script src="vendor/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -102,9 +102,15 @@
<hr />
<!-- User Info -->
<li class="nav-item">
<a class="nav-link">
<i class="fa fa-user"></i> Herbert
</a>
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa fa-user-circle"></i> <span id="username">%%USERNAME%%</span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" id="btnLogout">Logout</a></li>
</ul>
</div>
</li>
</ul>
</div>

View File

@ -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<Router> {
@ -46,6 +47,7 @@ pub(super) fn static_routes() -> Result<Router> {
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)

View File

@ -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<axum::body::Body>,
next: Next,
) -> Result<impl IntoResponse, (StatusCode, String)> {
@ -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());
}