Remove anonymous WebUI access

This commit is contained in:
Herbert Wolverson committed 2026-06-14 14:36:03 -05:00
1 parent e9e48ab153
commit 3359e6722c
4 files changed
+84 -61

No files matched your search

+57 -20
View File
@@ -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");
}
}
+20 -25
View File
@@ -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>