mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-26 06:40:05 -05:00
Remove anonymous WebUI access
This commit is contained in:
1 parent
e9e48ab153
commit
3359e6722c
4 files changed
+84
-61
No files matched your search
@@ -110,8 +110,6 @@ pub struct WebUsers {
|
||||
#[serde(default = "default_auth_epoch")]
|
||||
auth_epoch: u64,
|
||||
#[serde(default)]
|
||||
allow_unauthenticated_to_view: bool,
|
||||
#[serde(default)]
|
||||
users: Vec<WebUser>,
|
||||
#[serde(skip)]
|
||||
base_path_override: Option<PathBuf>,
|
||||
@@ -122,7 +120,6 @@ impl Default for WebUsers {
|
||||
Self {
|
||||
version: AUTH_FILE_VERSION,
|
||||
auth_epoch: INITIAL_AUTH_EPOCH,
|
||||
allow_unauthenticated_to_view: false,
|
||||
users: Vec::new(),
|
||||
base_path_override: None,
|
||||
}
|
||||
@@ -240,7 +237,11 @@ impl WebUsers {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_if_needed(&mut self, loaded_from: &Path) -> Result<(), AuthenticationError> {
|
||||
fn migrate_if_needed(
|
||||
&mut self,
|
||||
loaded_from: &Path,
|
||||
removed_anonymous_setting_present: bool,
|
||||
) -> Result<(), AuthenticationError> {
|
||||
let current_path = Self::primary_path_for(&self.resolved_base_path()?);
|
||||
let loaded_from_legacy_path = loaded_from != current_path;
|
||||
let mut needs_rewrite = false;
|
||||
@@ -259,6 +260,10 @@ impl WebUsers {
|
||||
needs_rewrite = true;
|
||||
}
|
||||
|
||||
if removed_anonymous_setting_present {
|
||||
needs_rewrite = true;
|
||||
}
|
||||
|
||||
if needs_rewrite {
|
||||
self.bump_auth_epoch();
|
||||
self.save_to_disk()?;
|
||||
@@ -279,12 +284,13 @@ impl WebUsers {
|
||||
error!("Unable to read auth file {:?}: {e}", path);
|
||||
AuthenticationError::UnableToRead
|
||||
})?;
|
||||
let removed_anonymous_setting_present = auth_file_has_removed_anonymous_setting(&raw);
|
||||
let mut users: Self = toml_edit::de::from_str(&raw).map_err(|e| {
|
||||
error!("Unable to deserialize auth file {:?}: {e}", path);
|
||||
AuthenticationError::UnableToParse
|
||||
})?;
|
||||
users.base_path_override = Some(Self::default_base_path()?);
|
||||
users.migrate_if_needed(&path)?;
|
||||
users.migrate_if_needed(&path, removed_anonymous_setting_present)?;
|
||||
Ok(users)
|
||||
} else {
|
||||
let new_users = Self::default();
|
||||
@@ -311,12 +317,13 @@ impl WebUsers {
|
||||
error!("Unable to read auth file {:?}: {e}", path);
|
||||
AuthenticationError::UnableToRead
|
||||
})?;
|
||||
let removed_anonymous_setting_present = auth_file_has_removed_anonymous_setting(&raw);
|
||||
let mut users: Self = toml_edit::de::from_str(&raw).map_err(|e| {
|
||||
error!("Unable to deserialize auth file {:?}: {e}", path);
|
||||
AuthenticationError::UnableToParse
|
||||
})?;
|
||||
users.base_path_override = Some(base_path.to_path_buf());
|
||||
users.migrate_if_needed(&path)?;
|
||||
users.migrate_if_needed(&path, removed_anonymous_setting_present)?;
|
||||
Ok(users)
|
||||
} else {
|
||||
let new_users = Self {
|
||||
@@ -483,21 +490,12 @@ impl WebUsers {
|
||||
pub fn get_users(&self) -> Vec<WebUser> {
|
||||
self.users.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the "allow unauthenticated users" field. If true,
|
||||
/// unauthenticated users gain read-only access. This is useful
|
||||
/// for demonstration purposes.
|
||||
pub fn allow_anonymous(&mut self, allow: bool) -> Result<(), AuthenticationError> {
|
||||
self.allow_unauthenticated_to_view = allow;
|
||||
self.bump_auth_epoch();
|
||||
self.save_to_disk()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Do we allow unauthenticated users to read site data?
|
||||
pub fn do_we_allow_anonymous(&self) -> bool {
|
||||
self.allow_unauthenticated_to_view
|
||||
}
|
||||
fn auth_file_has_removed_anonymous_setting(raw: &str) -> bool {
|
||||
raw.parse::<toml_edit::DocumentMut>()
|
||||
.map(|document| document.contains_key("allow_unauthenticated_to_view"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Errors that can occur while managing web-UI authentication.
|
||||
@@ -530,3 +528,42 @@ pub enum AuthenticationError {
|
||||
#[error("Invalid Login")]
|
||||
InvalidLogin,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn temp_auth_dir(test_name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("libreqos-auth-{test_name}-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_removes_legacy_anonymous_access_setting() {
|
||||
let dir = temp_auth_dir("anonymous-setting");
|
||||
fs::create_dir_all(&dir).expect("create auth test directory");
|
||||
let path = dir.join(CURRENT_AUTH_FILE_NAME);
|
||||
fs::write(
|
||||
&path,
|
||||
r#"version = 2
|
||||
auth_epoch = 1
|
||||
allow_unauthenticated_to_view = true
|
||||
|
||||
[[users]]
|
||||
username = "support"
|
||||
password_hash = "legacy"
|
||||
role = "ReadOnly"
|
||||
"#,
|
||||
)
|
||||
.expect("write auth file");
|
||||
|
||||
let users = WebUsers::load_or_create_in(&dir).expect("load auth file");
|
||||
assert_eq!(users.auth_epoch(), 2);
|
||||
assert_eq!(users.get_users().len(), 1);
|
||||
|
||||
let saved = fs::read_to_string(&path).expect("read migrated auth file");
|
||||
assert!(!saved.contains("allow_unauthenticated_to_view"));
|
||||
|
||||
fs::remove_dir_all(&dir).expect("remove auth test directory");
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@ struct CachedAuthSnapshot {
|
||||
struct AuthSnapshot {
|
||||
bootstrap_state: AuthBootstrapState,
|
||||
auth_epoch: u64,
|
||||
allow_anonymous: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -334,7 +333,6 @@ fn auth_snapshot() -> AuthSnapshot {
|
||||
return AuthSnapshot {
|
||||
bootstrap_state: AuthBootstrapState::CorruptUsersFile,
|
||||
auth_epoch: 0,
|
||||
allow_anonymous: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -350,7 +348,6 @@ fn auth_snapshot() -> AuthSnapshot {
|
||||
None => AuthSnapshot {
|
||||
bootstrap_state: AuthBootstrapState::MissingUsersFile,
|
||||
auth_epoch: 0,
|
||||
allow_anonymous: false,
|
||||
},
|
||||
Some(_) => match WebUsers::load_or_create() {
|
||||
Ok(users) => AuthSnapshot {
|
||||
@@ -360,14 +357,12 @@ fn auth_snapshot() -> AuthSnapshot {
|
||||
AuthBootstrapState::Ready
|
||||
},
|
||||
auth_epoch: users.auth_epoch(),
|
||||
allow_anonymous: users.do_we_allow_anonymous(),
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("Unable to load auth state: {e}");
|
||||
AuthSnapshot {
|
||||
bootstrap_state: AuthBootstrapState::CorruptUsersFile,
|
||||
auth_epoch: 0,
|
||||
allow_anonymous: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -597,14 +592,14 @@ pub async fn get_username(jar: &CookieJar) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum LoginResult {
|
||||
Admin,
|
||||
ReadOnly,
|
||||
Denied,
|
||||
}
|
||||
|
||||
fn login_result_for_session(user: Option<SessionUser>, allow_anonymous: bool) -> LoginResult {
|
||||
fn login_result_for_session(user: Option<SessionUser>) -> LoginResult {
|
||||
match user {
|
||||
Some(SessionUser {
|
||||
role: UserRole::Admin,
|
||||
@@ -614,7 +609,6 @@ fn login_result_for_session(user: Option<SessionUser>, allow_anonymous: bool) ->
|
||||
role: UserRole::ReadOnly,
|
||||
..
|
||||
}) => LoginResult::ReadOnly,
|
||||
None if allow_anonymous => LoginResult::ReadOnly,
|
||||
None => LoginResult::Denied,
|
||||
}
|
||||
}
|
||||
@@ -649,7 +643,7 @@ fn runtime_onboarding_exempt_path(path: &str) -> bool {
|
||||
/// Checks an incoming request for a `User-Token` cookie. If found,
|
||||
/// it validates the request against the signed session and current auth epoch.
|
||||
/// Missing or empty auth state redirects to first-run; invalid sessions redirect
|
||||
/// to login unless anonymous read-only is enabled.
|
||||
/// to login.
|
||||
pub async fn auth_layer(
|
||||
jar: CookieJar,
|
||||
mut req: axum::extract::Request,
|
||||
@@ -667,7 +661,7 @@ pub async fn auth_layer(
|
||||
}
|
||||
|
||||
let login_result = match session_from_cookie(&jar, &snapshot) {
|
||||
Ok(user) => login_result_for_session(user, snapshot.allow_anonymous),
|
||||
Ok(user) => login_result_for_session(user),
|
||||
Err(status) => return (status, "Unable to validate session").into_response(),
|
||||
};
|
||||
|
||||
@@ -703,7 +697,7 @@ pub async fn login_from_token(token: &str) -> LoginResult {
|
||||
};
|
||||
|
||||
let login_result = match verify_signed_session(&key, token, &snapshot) {
|
||||
Ok(user) => login_result_for_session(user, snapshot.allow_anonymous),
|
||||
Ok(user) => login_result_for_session(user),
|
||||
Err(e) => {
|
||||
warn!("Unable to verify websocket session token: {e}");
|
||||
LoginResult::Denied
|
||||
@@ -881,7 +875,6 @@ pub async fn try_login(
|
||||
pub struct FirstUser {
|
||||
username: String,
|
||||
password: String,
|
||||
allow_anonymous: bool,
|
||||
}
|
||||
|
||||
pub async fn first_user(
|
||||
@@ -924,19 +917,6 @@ pub async fn first_user(
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
users
|
||||
.allow_anonymous(new_user.allow_anonymous)
|
||||
.map_err(|e| {
|
||||
warn!("Unable to set anonymous auth policy: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(LoginResponse {
|
||||
ok: false,
|
||||
reason: Some("auth_corrupt"),
|
||||
message: Some("Unable to update auth settings.".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
users
|
||||
.add_or_update_user(&new_user.username, &new_user.password, UserRole::Admin)
|
||||
.map_err(|e| {
|
||||
@@ -995,6 +975,21 @@ pub async fn first_user(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_session_is_denied() {
|
||||
assert_eq!(login_result_for_session(None), LoginResult::Denied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_read_only_session_keeps_read_only_role() {
|
||||
let user = SessionUser {
|
||||
username: "support".to_string(),
|
||||
role: UserRole::ReadOnly,
|
||||
};
|
||||
|
||||
assert_eq!(login_result_for_session(Some(user)), LoginResult::ReadOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_rate_limiter_blocks_after_failed_attempt_limit() {
|
||||
let mut limiter = LoginRateLimiter::new();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
$("#btnCreateUser").on('click', () => {
|
||||
let username = ($("#username").val() || "").trim();
|
||||
let password = $("#password").val();
|
||||
let anon = document.getElementById("allowAnonymous").checked;
|
||||
if (username === "") {
|
||||
alert("You must enter a username");
|
||||
return;
|
||||
@@ -12,7 +11,6 @@ $("#btnCreateUser").on('click', () => {
|
||||
}
|
||||
|
||||
let login = {
|
||||
allow_anonymous: anon,
|
||||
username: username,
|
||||
password: password
|
||||
}
|
||||
|
||||
@@ -42,23 +42,16 @@
|
||||
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>
|
||||
<p>Let's create a new admin user:</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>
|
||||
Your Username
|
||||
</td>
|
||||
</tr><tr>
|
||||
<td>
|
||||
Your Username
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" id="username" />
|
||||
</td>
|
||||
</tr>
|
||||
<td>
|
||||
<input type="text" id="username" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Your password</td>
|
||||
<td><input type="password" id="password" /></td>
|
||||
|
||||
Reference in new issue
Block a user