Refactor HTTP account system
CI / frontend (pull_request) Successful in 5m13s
CI / reuse (pull_request) Successful in 29s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m24s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s

This commit was merged in pull request #61.
This commit is contained in:
sjg
2026-08-10 23:47:51 +02:00
parent d539ff96e5
commit 3c3fc69542
30 changed files with 1115 additions and 462 deletions
+5 -5
View File
@@ -252,11 +252,11 @@ async fn async_init() -> DynResult<AppState> {
// Set HTTP frontend authentication config
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled;
frontend_runtime.http_auth.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
frontend_runtime.http_auth.control_passphrase =
cfg.frontends.http.auth.control_passphrase.clone();
frontend_runtime.http_auth.tx_access_control_enabled =
cfg.frontends.http.auth.tx_access_control_enabled;
frontend_runtime.http_auth.users_file = cfg.frontends.http.auth.users_file.clone();
frontend_runtime.http_auth.bootstrap_admin_username =
cfg.frontends.http.auth.bootstrap_admin_username.clone();
frontend_runtime.http_auth.bootstrap_admin_password =
cfg.frontends.http.auth.bootstrap_admin_password.clone();
frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl().as_secs();
frontend_runtime.http_auth.cookie_secure = cfg.frontends.http.auth.cookie_secure;
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
+6 -6
View File
@@ -257,9 +257,9 @@ impl Default for DecodeHistoryContext {
/// HTTP authentication configuration.
pub struct HttpAuthConfig {
pub enabled: bool,
pub rx_passphrase: Option<String>,
pub control_passphrase: Option<String>,
pub tx_access_control_enabled: bool,
pub users_file: String,
pub bootstrap_admin_username: Option<String>,
pub bootstrap_admin_password: Option<String>,
pub session_ttl_secs: u64,
pub cookie_secure: bool,
pub cookie_same_site: String,
@@ -271,9 +271,9 @@ impl Default for HttpAuthConfig {
fn default() -> Self {
Self {
enabled: false,
rx_passphrase: None,
control_passphrase: None,
tx_access_control_enabled: true,
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: None,
bootstrap_admin_password: None,
session_ttl_secs: 480 * 60,
cookie_secure: false,
cookie_same_site: "Lax".to_string(),
@@ -28,6 +28,7 @@ flate2 = { workspace = true }
brotli = "7"
rand = "0.8"
hex = "0.4"
argon2 = "0.5"
pickledb = "0.5"
dirs = "6"
uuid = { workspace = true }
@@ -1323,7 +1323,7 @@ function decodeAuthSession(value) {
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== void 0 && session.role !== "rx" && session.role !== "control") {
if (session.role !== void 0 && session.role !== "user" && session.role !== "admin") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
@@ -1331,12 +1331,16 @@ function decodeAuthSession(value) {
}
const decoded = { authenticated: session.authenticated };
if (session.role !== void 0) decoded.role = session.role;
if (session.username !== void 0) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
var authDisabledSession = {
authenticated: true,
role: "control",
role: "admin",
auth_disabled: true
};
async function fetchAuthSession() {
@@ -1350,11 +1354,11 @@ async function fetchAuthSession() {
return { authenticated: false };
}
}
async function login(passphrase) {
async function login(username, password) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase })
body: JSON.stringify({ username, password })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
@@ -1363,6 +1367,34 @@ async function login(passphrase) {
}
return decodeAuthSession(await response.json());
}
async function userRequest(path, init) {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
async function listUsers() {
const value = await userRequest("/auth/users").then((response) => response.json());
if (!Array.isArray(value) || !value.every((user) => {
if (typeof user !== "object" || user === null) return false;
const record = user;
return typeof record.username === "string" && (record.role === "user" || record.role === "admin");
})) {
throw new TypeError("The user list response is malformed");
}
return value;
}
async function createUser(username, password, role) {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) });
}
async function updateUser(username, changes) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
async function deleteUser(username) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
async function logout() {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
@@ -1863,32 +1895,32 @@ function isVchanRdsEntry(value) {
}
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null;
var authUsername = null;
var authEnabled = true;
async function checkAuthStatus() {
return fetchAuthSession();
}
async function authLogin(passphrase) {
return login(passphrase);
async function authLogin(username, password) {
return login(username, password);
}
async function authLogout() {
try {
await logout();
authRole = null;
authUsername = null;
disconnect();
setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none";
requiredElement("auth-passphrase").value = "";
requiredElement("auth-password").value = "";
updateAuthUI();
const authStatus = await checkAuthStatus();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
} catch (e) {
console.error("Logout failed:", e);
showAuthError("Logout failed");
}
}
function showAuthGate(allowGuest = false) {
function showAuthGate() {
if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none";
@@ -1905,10 +1937,6 @@ function showAuthGate(allowGuest = false) {
document.querySelectorAll(".tab-panel").forEach((panel) => {
panel.style.display = "none";
});
const guestBtn2 = document.getElementById("auth-guest-btn");
if (guestBtn2) {
guestBtn2.style.display = allowGuest ? "block" : "none";
}
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main");
});
@@ -1952,7 +1980,7 @@ function updateAuthUI() {
}
if (authRole) {
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRole === "admin" ? "Admin" : "User (read-only)"}`;
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Logout";
headerAuthBtn2.style.display = "block";
@@ -1968,7 +1996,7 @@ function updateAuthUI() {
}
function applyAuthRestrictions() {
if (!authRole) return;
if (authRole === "rx") {
if (authRole === "user") {
const pttBtn2 = document.getElementById("ptt-btn");
const powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn");
@@ -2271,7 +2299,7 @@ function syncTopBarAccess() {
rigSwitch.style.display = loggedOut ? "none" : "";
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0;
}
}
var overviewDrawPending = false;
@@ -2907,7 +2935,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -4510,7 +4538,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
if (authRole === "rx") {
if (authRole === "user") {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -5253,8 +5281,8 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "rx") {
showHint("Control role required", 1500);
if (authRole === "user") {
showHint("Admin role required", 1500);
return;
}
if (!lastRigIds.includes(selectEl.value)) {
@@ -5868,7 +5896,7 @@ function navigateToTab(name, options = {}) {
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
showAuthGate();
return;
}
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5981,11 +6009,11 @@ window.addEventListener("resize", () => {
scheduleSpectrumLayout();
});
async function initializeApp() {
showAuthGate(false);
showAuthGate();
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "control";
authRole = "admin";
hideAuthGate();
updateAuthUI();
connect();
@@ -5997,6 +6025,7 @@ async function initializeApp() {
}
if (authStatus.authenticated) {
authRole = authStatus.role ?? null;
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -6006,8 +6035,7 @@ async function initializeApp() {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
}
}
var settingsUiReady = false;
@@ -6019,19 +6047,104 @@ function initSettingsUI() {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
if (!section) return;
section.style.display = authEnabled && authRole === "admin" ? "block" : "none";
if (section.style.display === "none") return;
const list = requiredElement("user-list");
try {
const users = await listUsers();
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
const role = document.createElement("select");
role.className = "auth-input";
for (const value of ["user", "admin"]) {
const option = document.createElement("option");
option.value = value;
option.textContent = value === "admin" ? "Admin" : "User";
option.selected = user.role === value;
role.append(option);
}
const password = document.createElement("input");
password.type = "password";
password.placeholder = "New password";
password.autocomplete = "new-password";
password.className = "auth-input";
password.minLength = 8;
const save = document.createElement("button");
save.type = "button";
save.textContent = "Save";
save.addEventListener("click", async () => {
const changes = { role: role.value };
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Remove";
remove.className = "danger";
remove.disabled = user.username === authUsername;
remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, role, password, save, remove);
return row;
}));
} catch (error) {
showUserManagementError(error);
}
}
function showUserManagementError(error) {
const element = document.getElementById("user-management-error");
if (!element) return;
element.textContent = error instanceof Error ? error.message : String(error);
element.style.display = "block";
}
async function runUserOperation(operation) {
try {
await operation();
const error = document.getElementById("user-management-error");
if (error) error.style.display = "none";
await refreshUserManagement();
} catch (reason) {
showUserManagementError(reason);
}
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement("user-create-username");
const password = requiredElement("user-create-password");
const role = requiredElement("user-create-role");
void runUserOperation(async () => {
await createUser(username.value, password.value, role.value);
username.value = "";
password.value = "";
role.value = "user";
});
});
requiredElement("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
const passphraseEl = requiredElement("auth-passphrase");
const passphrase = passphraseEl.value;
const usernameEl = requiredElement("auth-username");
const passwordEl = requiredElement("auth-password");
const btn = requiredElement("auth-form").querySelector("button[type=submit]");
if (!btn) return;
btn.disabled = true;
btn.textContent = "Logging in...";
try {
const result = await authLogin(passphrase);
const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null;
passphraseEl.value = "";
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -6041,28 +6154,13 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
showAuthError("Invalid passphrase");
showAuthError("Invalid username or password");
console.error("Login error:", err);
} finally {
btn.disabled = false;
btn.textContent = "Login";
}
});
var guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
connectDecode();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
}
var headerAuthBtn = document.getElementById("header-auth-btn");
if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => {
@@ -6071,7 +6169,7 @@ if (headerAuthBtn) {
await authLogout();
}
} else {
showAuthGate(false);
showAuthGate();
}
});
}
@@ -358,7 +358,7 @@ var bgdWindow = window;
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole() {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
@@ -42,7 +42,7 @@ function bmEsc(str) {
return d.innerHTML;
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled || hostState.authRole === "admin";
}
function bmSyncAccess() {
const canCtrl = bmCanControl();
@@ -272,7 +272,7 @@ function schedulerOptionalEl(id) {
const nextBtn = schedulerEl("scheduler-next-btn");
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
const enabled = schedulerRole === "admin" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
prevBtn.disabled = !enabled;
nextBtn.disabled = !enabled;
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
@@ -354,7 +354,7 @@ function schedulerOptionalEl(id) {
const panel = schedulerEl("scheduler-panel");
if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled";
const isControl = schedulerRole === "control";
const isControl = schedulerRole === "admin";
setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row");
@@ -123,13 +123,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="auth-gate" class="auth-gate" style="display:none;">
<div class="auth-gate-head">
<div class="auth-gate-title">Access Required</div>
<div class="auth-gate-sub">Enter passphrase to continue</div>
<div class="auth-gate-sub">Sign in to continue</div>
</div>
<form id="auth-form" class="auth-form">
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
<input type="text" id="auth-username" class="auth-input" placeholder="Username" autocomplete="username" required />
<input type="password" id="auth-password" class="auth-input" placeholder="Password" autocomplete="current-password" required />
<button type="submit" class="auth-submit">Login</button>
</form>
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
<div id="auth-error" class="auth-error" style="display: none;"></div>
<div id="auth-role" class="auth-role" style="display: none;"></div>
</div>
@@ -1750,6 +1750,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
</div>
<section id="user-management" style="display:none; margin-top:1rem;">
<h2 class="section-heading">User management</h2>
<div class="settings-card">
<form id="user-create-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="user-create-username" class="auth-input" placeholder="Username" autocomplete="off" required />
<input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" required />
<select id="user-create-role" class="auth-input"><option value="user">User</option><option value="admin">Admin</option></select>
<button type="submit" class="auth-submit">Add user</button>
</form>
<div id="user-management-error" class="auth-error" style="display:none;"></div>
<div id="user-list" style="margin-top:.75rem;"></div>
</div>
</section>
</div>
<div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2>
@@ -196,8 +196,7 @@ body {
font-size: var(--fs-base);
box-sizing: border-box;
}
.auth-submit,
.auth-guest {
.auth-submit {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: var(--radius-md);
@@ -213,16 +212,11 @@ body {
font-weight: 700;
}
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
.auth-guest {
background: var(--btn-bg);
color: var(--text);
border: 1px solid var(--border-light);
font-weight: 600;
margin-top: var(--space-4);
}
.auth-guest:hover:not(:disabled) { background: var(--btn-hover-bg); }
.auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); }
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); }
#user-management .auth-input { width: auto; min-width: 9rem; flex: 1 1 10rem; margin-bottom: 0; }
#user-management .auth-submit { width: auto; }
#user-management button { padding: 0.55rem 0.75rem; }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
#tab-main .label > span {
@@ -2,11 +2,12 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "rx" | "control";
export type AuthRole = "user" | "admin";
export interface AuthSession {
authenticated: boolean;
role?: AuthRole;
username?: string;
auth_disabled?: boolean;
}
@@ -18,7 +19,7 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== undefined && session.role !== "rx" && session.role !== "control") {
if (session.role !== undefined && session.role !== "user" && session.role !== "admin") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
@@ -26,13 +27,17 @@ function decodeAuthSession(value: unknown): AuthSession {
}
const decoded: AuthSession = { authenticated: session.authenticated };
if (session.role !== undefined) decoded.role = session.role;
if (session.username !== undefined) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
const authDisabledSession: AuthSession = {
authenticated: true,
role: "control",
role: "admin",
auth_disabled: true,
};
@@ -48,11 +53,11 @@ export async function fetchAuthSession(): Promise<AuthSession> {
}
}
export async function login(passphrase: string): Promise<AuthSession> {
export async function login(username: string, password: string): Promise<AuthSession> {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }),
body: JSON.stringify({ username, password }),
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
@@ -62,6 +67,41 @@ export async function login(passphrase: string): Promise<AuthSession> {
return decodeAuthSession(await response.json());
}
export interface ManagedUser { username: string; role: AuthRole }
async function userRequest(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({})) as { error?: string };
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
export async function listUsers(): Promise<ManagedUser[]> {
const value: unknown = await userRequest("/auth/users").then(response => response.json());
if (!Array.isArray(value) || !value.every((user: unknown) => {
if (typeof user !== "object" || user === null) return false;
const record = user as Record<string, unknown>;
return typeof record.username === "string" && (record.role === "user" || record.role === "admin");
})) {
throw new TypeError("The user list response is malformed");
}
return value as ManagedUser[];
}
export async function createUser(username: string, password: string, role: AuthRole): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) });
}
export async function updateUser(username: string, changes: { password?: string; role?: AuthRole }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
export async function deleteUser(username: string): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
export async function logout(): Promise<void> {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
@@ -21,6 +21,10 @@ import {
fetchAuthSession,
login,
logout,
listUsers,
createUser,
updateUser,
deleteUser,
} from "./api/auth.js";
import {
formatByteSize as recorderFormatSize,
@@ -407,39 +411,38 @@ void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication ---
let authRole: AuthRole | null = null;
let authUsername: string | null = null;
let authEnabled = true;
async function checkAuthStatus() {
return fetchAuthSession();
}
async function authLogin(passphrase: string) {
return login(passphrase);
async function authLogin(username: string, password: string) {
return login(username, password);
}
async function authLogout() {
try {
await logout();
authRole = null;
authUsername = null;
// Disconnect and show auth gate without page reload
disconnect();
setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
requiredElement<HTMLInputElement>("auth-password").value = "";
updateAuthUI();
// Check if guest mode is available after logout
const authStatus = await checkAuthStatus();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
} catch (e) {
console.error("Logout failed:", e);
showAuthError("Logout failed");
}
}
function showAuthGate(allowGuest = false) {
function showAuthGate() {
if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none";
@@ -459,12 +462,6 @@ function showAuthGate(allowGuest = false) {
panel.style.display = "none";
});
// Show guest button if guest mode is available
const guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.style.display = allowGuest ? "block" : "none";
}
document.querySelectorAll<HTMLElement>(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main");
});
@@ -516,7 +513,7 @@ function updateAuthUI() {
if (authRole) {
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRole === "admin" ? "Admin" : "User (read-only)"}`;
if (headerAuthBtn) {
headerAuthBtn.textContent = "Logout";
headerAuthBtn.style.display = "block";
@@ -534,8 +531,8 @@ function updateAuthUI() {
function applyAuthRestrictions() {
if (!authRole) return;
// Disable TX/PTT/frequency/mode/VFO controls for rx role
if (authRole === "rx") {
// Disable TX/PTT/frequency/mode/VFO controls for user role
if (authRole === "user") {
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
@@ -883,7 +880,7 @@ function syncTopBarAccess() {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0;
}
}
@@ -1464,7 +1461,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -3352,7 +3349,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link: TuneLink) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
if (authRole === "rx") {
if (authRole === "user") {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -4191,8 +4188,8 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "rx") {
showHint("Control role required", 1500);
if (authRole === "user") {
showHint("Admin role required", 1500);
return;
}
if (!lastRigIds.includes(selectEl.value)) {
@@ -4875,7 +4872,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
showAuthGate();
return;
}
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5008,12 +5005,12 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
// --- Auth startup sequence ---
async function initializeApp() {
showAuthGate(false);
showAuthGate();
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "control";
authRole = "admin";
hideAuthGate();
updateAuthUI();
connect();
@@ -5027,6 +5024,7 @@ async function initializeApp() {
if (authStatus.authenticated) {
// User has valid session
authRole = authStatus.role ?? null;
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5036,10 +5034,7 @@ async function initializeApp() {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
// No valid session - show auth gate
// Guest button is shown if guest mode is available (role granted without auth)
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
}
}
@@ -5055,22 +5050,96 @@ function initSettingsUI() {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
if (!section) return;
section.style.display = authEnabled && authRole === "admin" ? "block" : "none";
if (section.style.display === "none") return;
const list = requiredElement("user-list");
try {
const users = await listUsers();
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
const role = document.createElement("select");
role.className = "auth-input";
for (const value of ["user", "admin"] as AuthRole[]) {
const option = document.createElement("option"); option.value = value; option.textContent = value === "admin" ? "Admin" : "User"; option.selected = user.role === value; role.append(option);
}
const password = document.createElement("input");
password.type = "password"; password.placeholder = "New password"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8;
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
save.addEventListener("click", async () => {
const changes: { role?: AuthRole; password?: string } = { role: role.value as AuthRole };
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger";
remove.disabled = user.username === authUsername;
remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, role, password, save, remove);
return row;
}));
} catch (error) {
showUserManagementError(error);
}
}
function showUserManagementError(error: unknown) {
const element = document.getElementById("user-management-error");
if (!element) return;
element.textContent = error instanceof Error ? error.message : String(error);
element.style.display = "block";
}
async function runUserOperation(operation: () => Promise<void>) {
try {
await operation();
const error = document.getElementById("user-management-error");
if (error) error.style.display = "none";
await refreshUserManagement();
} catch (reason) {
showUserManagementError(reason);
}
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement<HTMLInputElement>("user-create-username");
const password = requiredElement<HTMLInputElement>("user-create-password");
const role = requiredElement<HTMLSelectElement>("user-create-role");
void runUserOperation(async () => {
await createUser(username.value, password.value, role.value as AuthRole);
username.value = ""; password.value = ""; role.value = "user";
});
});
// Setup auth form
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
const passphraseEl = requiredElement<HTMLInputElement>("auth-passphrase");
const passphrase = passphraseEl.value;
const usernameEl = requiredElement<HTMLInputElement>("auth-username");
const passwordEl = requiredElement<HTMLInputElement>("auth-password");
const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]");
if (!btn) return;
btn.disabled = true;
btn.textContent = "Logging in...";
try {
const result = await authLogin(passphrase);
const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null;
passphraseEl.value = "";
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5080,7 +5149,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
showAuthError("Invalid passphrase");
showAuthError("Invalid username or password");
console.error("Login error:", err);
} finally {
btn.disabled = false;
@@ -5088,23 +5157,6 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
}
});
// Setup guest button
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
if (guestBtn) {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
connectDecode();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
}
// Setup header auth button (Login/Logout)
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
if (headerAuthBtn) {
@@ -5116,7 +5168,7 @@ if (headerAuthBtn) {
}
} else {
// Not logged in - show auth gate
showAuthGate(false);
showAuthGate();
}
});
}
@@ -468,7 +468,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function isControlRole(): boolean {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
}
function showToast(msg: string, isError: boolean): void {
@@ -100,7 +100,7 @@ function bmEsc(str: unknown): string {
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled || hostState.authRole === "admin";
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -356,7 +356,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled =
schedulerRole === "control" &&
schedulerRole === "admin" &&
!!currentRigId &&
!schedulerStepPending &&
state.activeEntries.length > 1;
@@ -466,7 +466,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "control";
const isControl = schedulerRole === "admin";
// Mode selector
setSelected("scheduler-mode-select", mode);
@@ -40,7 +40,7 @@ test("background decode loads configuration for the explicitly selected rig", as
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context);
window.trx.modules.backgroundDecode.initialize("rig/a", "control");
window.trx.modules.backgroundDecode.initialize("rig/a", "admin");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks"));
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "control",
authRole: "admin",
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -157,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
const { window } = hostFixture({ authEnabled: true, authRole: "user" });
const context = vm.createContext({
window,
document: documentFixture(element),
@@ -171,7 +171,7 @@ test("bookmark controls follow the host authentication state", async () => {
assert.equal(element("bm-add-btn").style.display, "none");
window.trx.state.authRole = "control";
window.trx.state.authRole = "admin";
await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, "");
});
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null,
serverLon: null,
authEnabled: false,
authRole: "control",
authRole: "admin",
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id);
};
const window = {
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }),
...createHost({ state: { authRole: "admin", lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true },
};
const context = vm.createContext({
@@ -232,7 +232,7 @@ export async function startWebFixture({
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/auth/session", { authenticated: true, role: "admin", auth_disabled: true }],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],
@@ -399,8 +399,8 @@ fn require_control(
return Ok(());
}
match crate::server::auth::get_session_role(req, auth_state) {
Some(crate::server::auth::AuthRole::Control) => Ok(()),
_ => Err(actix_web::error::ErrorForbidden("control role required")),
Some(crate::server::auth::AuthRole::Admin) => Ok(()),
_ => Err(actix_web::error::ErrorForbidden("admin role required")),
}
}
@@ -709,6 +709,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(crate::server::auth::login)
.service(crate::server::auth::logout)
.service(crate::server::auth::session_status)
.service(crate::server::auth::list_users)
.service(crate::server::auth::create_user)
.service(crate::server::auth::update_user)
.service(crate::server::auth::delete_user)
// Logbook
.service(logbook::list_qsos)
.service(logbook::add_qso)
@@ -949,30 +953,33 @@ mod tests {
);
}
/// Auth off: every session may write, as a station with no passphrase set.
/// Auth off: every session may write without an account.
fn auth_state_disabled() -> crate::server::auth::AuthState {
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
false,
std::path::PathBuf::from("unused-users.json"),
None,
None,
false,
std::time::Duration::from_secs(3600),
false,
crate::server::auth::SameSite::Lax,
))
.unwrap()
}
/// Auth on with no session presented, which is what a listener is.
fn auth_state_locked() -> crate::server::auth::AuthState {
let directory = tempfile::tempdir().unwrap();
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
true,
Some("listen".to_string()),
Some("control".to_string()),
false,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
std::time::Duration::from_secs(3600),
false,
crate::server::auth::SameSite::Lax,
))
.unwrap()
}
/// A contact written over HTTP comes back out of the log, and out of an
@@ -4,18 +4,24 @@
//! HTTP authentication module for trx-frontend-http.
//!
//! Provides optional session-based authentication with two roles:
//! - `Rx`: read-only access to status/events/audio
//! - `Control`: full access including TX/PTT control
//! Provides optional session-based authentication backed by a persistent,
//! administrator-managed user database.
use actix_web::{
cookie::Cookie,
delete,
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
get, post, web, Error, HttpRequest, HttpResponse, Responder,
get, patch, post, web, Error, HttpRequest, HttpResponse, Responder,
};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use futures_util::future::LocalBoxFuture;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime};
use tracing::warn;
@@ -27,17 +33,17 @@ pub type SessionId = String;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthRole {
/// Read-only access (rx passphrase)
Rx,
/// Full control access (control passphrase)
Control,
/// Read-only access.
User,
/// Full control and user-management access.
Admin,
}
impl AuthRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Rx => "rx",
Self::Control => "control",
Self::User => "user",
Self::Admin => "admin",
}
}
}
@@ -45,6 +51,7 @@ impl AuthRole {
/// Session record stored in the session store
#[derive(Debug, Clone)]
pub struct SessionRecord {
pub username: String,
pub role: AuthRole,
pub issued_at: SystemTime,
pub expires_at: SystemTime,
@@ -75,12 +82,13 @@ impl SessionStore {
}
/// Create a new session with the given role and TTL
pub fn create(&self, role: AuthRole, ttl: Duration) -> SessionId {
pub fn create(&self, username: String, role: AuthRole, ttl: Duration) -> SessionId {
let now = SystemTime::now();
let expires_at = now + ttl;
let session_id = Self::generate_session_id();
let record = SessionRecord {
username,
role,
issued_at: now,
expires_at,
@@ -121,6 +129,12 @@ impl SessionStore {
store.remove(session_id);
}
/// Invalidate every session belonging to a user.
pub fn remove_user(&self, username: &str) {
let mut store = self.sessions.write().unwrap_or_else(|e| e.into_inner());
store.retain(|_, record| record.username != username);
}
/// Remove all expired sessions
pub fn cleanup_expired(&self) {
let mut store = self.sessions.write().unwrap_or_else(|e| {
@@ -167,9 +181,9 @@ impl SameSite {
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub enabled: bool,
pub rx_passphrase: Option<String>,
pub control_passphrase: Option<String>,
pub tx_access_control_enabled: bool,
pub users_file: PathBuf,
pub bootstrap_admin_username: Option<String>,
pub bootstrap_admin_password: Option<String>,
pub session_ttl: Duration,
pub cookie_secure: bool,
pub cookie_same_site: SameSite,
@@ -179,39 +193,236 @@ impl AuthConfig {
/// Create a new auth config with all fields
pub fn new(
enabled: bool,
rx_passphrase: Option<String>,
control_passphrase: Option<String>,
tx_access_control_enabled: bool,
users_file: PathBuf,
bootstrap_admin_username: Option<String>,
bootstrap_admin_password: Option<String>,
session_ttl: Duration,
cookie_secure: bool,
cookie_same_site: SameSite,
) -> Self {
Self {
enabled,
rx_passphrase,
control_passphrase,
tx_access_control_enabled,
users_file,
bootstrap_admin_username,
bootstrap_admin_password,
session_ttl,
cookie_secure,
cookie_same_site,
}
}
}
/// Check passphrase and return the corresponding role
pub fn check_passphrase(&self, passphrase: &str) -> Option<AuthRole> {
// Use constant-time comparison to reduce timing attacks
if let Some(ctrl_pass) = &self.control_passphrase {
if constant_time_eq(passphrase, ctrl_pass) {
return Some(AuthRole::Control);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserRecord {
pub username: String,
pub password_hash: String,
pub role: AuthRole,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct UserDatabase {
users: Vec<UserRecord>,
}
/// Persistent user database. Passwords are Argon2id hashes; writes replace the
/// database atomically so an interrupted update cannot truncate it.
pub struct UserStore {
path: PathBuf,
users: RwLock<HashMap<String, UserRecord>>,
}
impl UserStore {
fn open(config: &AuthConfig) -> Result<Self, String> {
if !config.enabled {
return Ok(Self {
path: config.users_file.clone(),
users: RwLock::new(HashMap::new()),
});
}
let records = if config.users_file.exists() {
let bytes = fs::read(&config.users_file)
.map_err(|e| format!("read {}: {e}", config.users_file.display()))?;
let db: UserDatabase = serde_json::from_slice(&bytes)
.map_err(|e| format!("parse {}: {e}", config.users_file.display()))?;
db.users
} else {
let username = config.bootstrap_admin_username.as_deref().ok_or_else(|| {
format!(
"user database {} does not exist and no bootstrap administrator was configured",
config.users_file.display()
)
})?;
let password = config
.bootstrap_admin_password
.as_deref()
.ok_or_else(|| "bootstrap administrator password is missing".to_string())?;
vec![UserRecord {
username: validate_username(username)?.to_string(),
password_hash: hash_password(password)?,
role: AuthRole::Admin,
}]
};
let mut users = HashMap::new();
for record in records {
validate_username(&record.username)?;
if users.insert(record.username.clone(), record).is_some() {
return Err("user database contains duplicate usernames".to_string());
}
}
if let Some(rx_pass) = &self.rx_passphrase {
if constant_time_eq(passphrase, rx_pass) {
return Some(AuthRole::Rx);
}
if !users.values().any(|u| u.role == AuthRole::Admin) {
return Err("user database must contain at least one administrator".to_string());
}
None
let store = Self {
path: config.users_file.clone(),
users: RwLock::new(users),
};
if !store.path.exists() {
store.persist()?;
}
Ok(store)
}
fn authenticate(&self, username: &str, password: &str) -> Option<AuthRole> {
let users = self.users.read().unwrap_or_else(|e| e.into_inner());
let record = users.get(username)?;
let parsed = PasswordHash::new(&record.password_hash).ok()?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.ok()?;
Some(record.role)
}
fn list(&self) -> Vec<ManagedUser> {
let users = self.users.read().unwrap_or_else(|e| e.into_inner());
let mut result: Vec<_> = users
.values()
.map(|u| ManagedUser {
username: u.username.clone(),
role: u.role,
})
.collect();
result.sort_by(|a, b| a.username.cmp(&b.username));
result
}
fn add(&self, username: &str, password: &str, role: AuthRole) -> Result<(), String> {
let username = validate_username(username)?.to_string();
validate_password(password)?;
let record = UserRecord {
username: username.clone(),
password_hash: hash_password(password)?,
role,
};
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
if users.contains_key(&username) {
return Err("user already exists".to_string());
}
users.insert(username.clone(), record);
if let Err(error) = persist_users(&self.path, &users) {
users.remove(&username);
return Err(error);
}
Ok(())
}
fn update(
&self,
username: &str,
password: Option<&str>,
role: Option<AuthRole>,
) -> Result<(), String> {
if let Some(value) = password {
validate_password(value)?;
}
let new_hash = password.map(hash_password).transpose()?;
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
let old = users
.get(username)
.cloned()
.ok_or_else(|| "user not found".to_string())?;
if old.role == AuthRole::Admin
&& role == Some(AuthRole::User)
&& users.values().filter(|u| u.role == AuthRole::Admin).count() == 1
{
return Err("cannot demote the last administrator".to_string());
}
let record = users.get_mut(username).expect("checked above");
if let Some(hash) = new_hash {
record.password_hash = hash;
}
if let Some(value) = role {
record.role = value;
}
if let Err(error) = persist_users(&self.path, &users) {
users.insert(username.to_string(), old);
return Err(error);
}
Ok(())
}
fn remove(&self, username: &str) -> Result<(), String> {
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
let old = users
.get(username)
.cloned()
.ok_or_else(|| "user not found".to_string())?;
if old.role == AuthRole::Admin
&& users.values().filter(|u| u.role == AuthRole::Admin).count() == 1
{
return Err("cannot remove the last administrator".to_string());
}
users.remove(username);
if let Err(error) = persist_users(&self.path, &users) {
users.insert(username.to_string(), old);
return Err(error);
}
Ok(())
}
fn persist(&self) -> Result<(), String> {
let users = self.users.read().unwrap_or_else(|e| e.into_inner());
persist_users(&self.path, &users)
}
}
fn persist_users(path: &Path, users: &HashMap<String, UserRecord>) -> Result<(), String> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
let mut records: Vec<_> = users.values().cloned().collect();
records.sort_by(|a, b| a.username.cmp(&b.username));
let bytes =
serde_json::to_vec_pretty(&UserDatabase { users: records }).map_err(|e| e.to_string())?;
let temporary = path.with_extension("tmp");
fs::write(&temporary, bytes).map_err(|e| format!("write {}: {e}", temporary.display()))?;
fs::rename(&temporary, path).map_err(|e| format!("replace {}: {e}", path.display()))
}
fn hash_password(password: &str) -> Result<String, String> {
validate_password(password)?;
Argon2::default()
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
.map(|hash| hash.to_string())
.map_err(|e| format!("password hashing failed: {e}"))
}
fn validate_username(username: &str) -> Result<&str, String> {
if username.is_empty()
|| username.len() > 64
|| !username
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err("username must be 1-64 ASCII letters, digits, '.', '-' or '_'".to_string());
}
Ok(username)
}
fn validate_password(password: &str) -> Result<(), String> {
if password.len() < 8 {
return Err("password must contain at least 8 characters".to_string());
}
Ok(())
}
/// Simple per-IP rate limiter for login attempts.
@@ -283,39 +494,27 @@ impl Default for LoginRateLimiter {
pub struct AuthState {
pub config: AuthConfig,
pub store: SessionStore,
pub users: UserStore,
pub rate_limiter: LoginRateLimiter,
}
impl AuthState {
pub fn new(config: AuthConfig) -> Self {
Self {
pub fn new(config: AuthConfig) -> Result<Self, String> {
let users = UserStore::open(&config)?;
Ok(Self {
config,
store: SessionStore::new(),
users,
rate_limiter: LoginRateLimiter::default(),
}
})
}
}
/// Constant-time string comparison to mitigate timing attacks
fn constant_time_eq(a: &str, b: &str) -> bool {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
if a_bytes.len() != b_bytes.len() {
return false;
}
let mut result = 0u8;
for (x, y) in a_bytes.iter().zip(b_bytes.iter()) {
result |= x ^ y;
}
result == 0
}
/// Login request body
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub passphrase: String,
pub username: String,
pub password: String,
}
/// Session status response
@@ -323,6 +522,8 @@ pub struct LoginRequest {
pub struct SessionStatus {
pub authenticated: bool,
pub role: Option<String>,
pub username: Option<String>,
pub auth_disabled: bool,
}
/// Login response
@@ -330,6 +531,26 @@ pub struct SessionStatus {
pub struct LoginResponse {
pub authenticated: bool,
pub role: String,
pub username: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ManagedUser {
pub username: String,
pub role: AuthRole,
}
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub password: String,
pub role: AuthRole,
}
#[derive(Debug, Deserialize)]
pub struct UpdateUserRequest {
pub password: Option<String>,
pub role: Option<AuthRole>,
}
/// Extract session from cookie
@@ -345,6 +566,20 @@ pub fn get_session_role(req: &HttpRequest, auth_state: &AuthState) -> Option<Aut
Some(record.role)
}
fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result<SessionRecord, HttpResponse> {
let session = extract_session_id(req)
.and_then(|id| auth_state.store.get(&id))
.ok_or_else(|| {
HttpResponse::Unauthorized()
.json(serde_json::json!({"error":"authentication required"}))
})?;
if session.role != AuthRole::Admin {
return Err(HttpResponse::Forbidden()
.json(serde_json::json!({"error":"administrator role required"})));
}
Ok(session)
}
// ============================================================================
// Endpoints
// ============================================================================
@@ -371,8 +606,10 @@ pub async fn login(
})));
}
// Check passphrase
let role = match auth_state.config.check_passphrase(&body.passphrase) {
let role = match auth_state
.users
.authenticate(&body.username, &body.password)
{
Some(r) => r,
None => {
return Ok(HttpResponse::Unauthorized().json(serde_json::json!({
@@ -385,7 +622,10 @@ pub async fn login(
auth_state.rate_limiter.reset(&peer_ip);
// Create session
let session_id = auth_state.store.create(role, auth_state.config.session_ttl);
let session_id =
auth_state
.store
.create(body.username.clone(), role, auth_state.config.session_ttl);
let mut cookie = Cookie::new("trx_http_sid", session_id);
cookie.set_path("/");
@@ -406,6 +646,7 @@ pub async fn login(
Ok(HttpResponse::Ok().cookie(cookie).json(LoginResponse {
authenticated: true,
role: role.as_str().to_string(),
username: body.username.clone(),
}))
}
@@ -445,7 +686,9 @@ pub async fn session_status(
if !auth_state.config.enabled {
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true,
role: Some("control".to_string()),
role: Some("admin".to_string()),
username: None,
auth_disabled: true,
}));
}
@@ -455,15 +698,8 @@ pub async fn session_status(
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true,
role: Some(session_record.role.as_str().to_string()),
}));
}
// No session - check if rx access is unrestricted
if auth_state.config.rx_passphrase.is_none() {
// No rx passphrase required - grant rx role to unauthenticated users
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: false,
role: Some("rx".to_string()),
username: Some(session_record.username),
auth_disabled: false,
}));
}
@@ -471,9 +707,93 @@ pub async fn session_status(
Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: false,
role: None,
username: None,
auth_disabled: false,
}))
}
/// GET /auth/users
#[get("/auth/users")]
pub async fn list_users(req: HttpRequest, auth_state: web::Data<AuthState>) -> impl Responder {
if let Err(response) = require_admin(&req, &auth_state) {
return response;
}
HttpResponse::Ok().json(auth_state.users.list())
}
/// POST /auth/users
#[post("/auth/users")]
pub async fn create_user(
req: HttpRequest,
body: web::Json<CreateUserRequest>,
auth_state: web::Data<AuthState>,
) -> impl Responder {
if let Err(response) = require_admin(&req, &auth_state) {
return response;
}
match auth_state
.users
.add(&body.username, &body.password, body.role)
{
Ok(()) => HttpResponse::Created().json(ManagedUser {
username: body.username.clone(),
role: body.role,
}),
Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})),
}
}
/// PATCH /auth/users/{username}
#[patch("/auth/users/{username}")]
pub async fn update_user(
req: HttpRequest,
username: web::Path<String>,
body: web::Json<UpdateUserRequest>,
auth_state: web::Data<AuthState>,
) -> impl Responder {
if let Err(response) = require_admin(&req, &auth_state) {
return response;
}
if body.password.is_none() && body.role.is_none() {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error":"password or role is required"}));
}
match auth_state
.users
.update(&username, body.password.as_deref(), body.role)
{
Ok(()) => {
auth_state.store.remove_user(&username);
HttpResponse::Ok().json(serde_json::json!({"updated": true}))
}
Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})),
}
}
/// DELETE /auth/users/{username}
#[delete("/auth/users/{username}")]
pub async fn delete_user(
req: HttpRequest,
username: web::Path<String>,
auth_state: web::Data<AuthState>,
) -> impl Responder {
let admin = match require_admin(&req, &auth_state) {
Ok(value) => value,
Err(response) => return response,
};
if admin.username == *username {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error":"administrators cannot remove their own account"}));
}
match auth_state.users.remove(&username) {
Ok(()) => {
auth_state.store.remove_user(&username);
HttpResponse::Ok().json(serde_json::json!({"deleted": true}))
}
Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})),
}
}
// ============================================================================
// Middleware
// ============================================================================
@@ -483,9 +803,9 @@ pub async fn session_status(
enum RouteAccess {
/// Publicly accessible (no auth required)
Public,
/// Read-only (rx or control role required)
/// Read-only (user or admin role required)
Read,
/// Control only (control role required)
/// Control only (admin role required)
Control,
}
@@ -499,7 +819,9 @@ impl RouteAccess {
|| path == "/digital-modes"
|| path == "/settings"
|| path == "/about"
|| path.starts_with("/auth/")
|| path == "/auth/login"
|| path == "/auth/logout"
|| path == "/auth/session"
{
return Self::Public;
}
@@ -561,7 +883,7 @@ impl RouteAccess {
match self {
Self::Public => true,
Self::Read => role.is_some(),
Self::Control => matches!(role, Some(AuthRole::Control)),
Self::Control => matches!(role, Some(AuthRole::Admin)),
}
}
}
@@ -631,19 +953,6 @@ where
// Auth enabled - check role
let role = get_session_role(req.request(), &auth_state);
// If rx_passphrase is not set, allow unauthenticated read access
let allow_unrestricted_read = auth_state.config.rx_passphrase.is_none();
let is_read_route = access == RouteAccess::Read;
if is_read_route && allow_unrestricted_read {
// No rx authentication required - allow read access without role
let fut = self.service.call(req);
return Box::pin(async move {
let res = fut.await?;
Ok(res)
});
}
if !access.allows(role) {
// Access denied
return Box::pin(async move {
@@ -652,12 +961,6 @@ where
Err(actix_web::error::ErrorForbidden(
"Insufficient permissions".to_string(),
))
} else if allow_unrestricted_read {
// No session but rx access is unrestricted - 403 Forbidden
// (user has implicit rx role from unrestricted access)
Err(actix_web::error::ErrorForbidden(
"Insufficient permissions".to_string(),
))
} else {
// No session and no unrestricted access - 401 Unauthorized
Err(actix_web::error::ErrorUnauthorized(
@@ -676,18 +979,10 @@ where
}
}
/// Check if a path is a TX/PTT endpoint (used for TX access control).
pub fn is_tx_endpoint(path: &str) -> bool {
path.contains("ptt")
|| path.contains("set_ptt")
|| path.contains("toggle_ptt")
|| path.contains("set_tx")
|| path.contains("toggle_tx")
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{test as aw_test, App};
#[test]
fn test_route_access_public_paths() {
@@ -701,6 +996,7 @@ mod tests {
assert_eq!(RouteAccess::from_path("/about"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/login"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/users"), RouteAccess::Control);
assert_eq!(RouteAccess::from_path("/style.css"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public);
// Static reference data, served to every role: ".json" is not in the
@@ -735,28 +1031,29 @@ mod tests {
#[test]
fn test_route_access_allows() {
assert!(RouteAccess::Public.allows(None));
assert!(RouteAccess::Public.allows(Some(AuthRole::Rx)));
assert!(RouteAccess::Public.allows(Some(AuthRole::Control)));
assert!(RouteAccess::Public.allows(Some(AuthRole::User)));
assert!(RouteAccess::Public.allows(Some(AuthRole::Admin)));
assert!(!RouteAccess::Read.allows(None));
assert!(RouteAccess::Read.allows(Some(AuthRole::Rx)));
assert!(RouteAccess::Read.allows(Some(AuthRole::Control)));
assert!(RouteAccess::Read.allows(Some(AuthRole::User)));
assert!(RouteAccess::Read.allows(Some(AuthRole::Admin)));
assert!(!RouteAccess::Control.allows(None));
assert!(!RouteAccess::Control.allows(Some(AuthRole::Rx)));
assert!(RouteAccess::Control.allows(Some(AuthRole::Control)));
assert!(!RouteAccess::Control.allows(Some(AuthRole::User)));
assert!(RouteAccess::Control.allows(Some(AuthRole::Admin)));
}
#[test]
fn test_session_store_create_and_get() {
let store = SessionStore::new();
let ttl = Duration::from_secs(3600);
let session_id = store.create(AuthRole::Rx, ttl);
let session_id = store.create("alice".to_string(), AuthRole::User, ttl);
let record = store.get(&session_id);
assert!(record.is_some());
let record = record.unwrap();
assert_eq!(record.role, AuthRole::Rx);
assert_eq!(record.username, "alice");
assert_eq!(record.role, AuthRole::User);
assert!(!record.is_expired());
}
@@ -764,81 +1061,151 @@ mod tests {
fn test_session_store_remove() {
let store = SessionStore::new();
let ttl = Duration::from_secs(3600);
let session_id = store.create(AuthRole::Rx, ttl);
let session_id = store.create("alice".to_string(), AuthRole::User, ttl);
store.remove(&session_id);
assert!(store.get(&session_id).is_none());
}
#[test]
fn test_constant_time_eq() {
assert!(constant_time_eq("test", "test"));
assert!(!constant_time_eq("test", "fail"));
assert!(!constant_time_eq("test", "test2"));
assert!(!constant_time_eq("", "test"));
}
#[test]
fn test_auth_config_check_passphrase_control() {
let config = AuthConfig {
enabled: true,
rx_passphrase: None,
control_passphrase: Some("ctrl-pass".to_string()),
tx_access_control_enabled: true,
session_ttl: Duration::from_secs(3600),
cookie_secure: false,
cookie_same_site: SameSite::Lax,
};
assert_eq!(
config.check_passphrase("ctrl-pass"),
Some(AuthRole::Control)
fn user_store_bootstraps_and_manages_users() {
let directory = tempfile::tempdir().unwrap();
let config = AuthConfig::new(
true,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
Duration::from_secs(3600),
false,
SameSite::Lax,
);
assert_eq!(config.check_passphrase("wrong"), None);
}
#[test]
fn test_auth_config_check_passphrase_rx() {
let config = AuthConfig {
enabled: true,
rx_passphrase: Some("rx-pass".to_string()),
control_passphrase: None,
tx_access_control_enabled: true,
session_ttl: Duration::from_secs(3600),
cookie_secure: false,
cookie_same_site: SameSite::Lax,
};
assert_eq!(config.check_passphrase("rx-pass"), Some(AuthRole::Rx));
assert_eq!(config.check_passphrase("wrong"), None);
}
#[test]
fn test_auth_config_check_passphrase_both() {
let config = AuthConfig {
enabled: true,
rx_passphrase: Some("rx-pass".to_string()),
control_passphrase: Some("ctrl-pass".to_string()),
tx_access_control_enabled: true,
session_ttl: Duration::from_secs(3600),
cookie_secure: false,
cookie_same_site: SameSite::Lax,
};
// Control is checked first
let users = UserStore::open(&config).unwrap();
assert_eq!(
config.check_passphrase("ctrl-pass"),
Some(AuthRole::Control)
users.authenticate("admin", "password123"),
Some(AuthRole::Admin)
);
assert_eq!(config.check_passphrase("rx-pass"), Some(AuthRole::Rx));
assert_eq!(config.check_passphrase("wrong"), None);
assert_eq!(users.authenticate("admin", "wrong"), None);
users.add("alice", "password456", AuthRole::User).unwrap();
assert_eq!(
users.authenticate("alice", "password456"),
Some(AuthRole::User)
);
users
.update("alice", Some("password789"), Some(AuthRole::Admin))
.unwrap();
assert_eq!(
users.authenticate("alice", "password789"),
Some(AuthRole::Admin)
);
users.remove("admin").unwrap();
assert_eq!(users.list().len(), 1);
drop(users);
let mut reopen_config = config.clone();
reopen_config.bootstrap_admin_username = None;
reopen_config.bootstrap_admin_password = None;
let reopened = UserStore::open(&reopen_config).unwrap();
assert_eq!(
reopened.authenticate("alice", "password789"),
Some(AuthRole::Admin)
);
let database = fs::read_to_string(&reopen_config.users_file).unwrap();
assert!(database.contains("$argon2"));
assert!(!database.contains("password789"));
}
#[test]
fn test_is_tx_endpoint() {
assert!(is_tx_endpoint("/set_ptt"));
assert!(is_tx_endpoint("/toggle_ptt"));
assert!(is_tx_endpoint("/set_tx"));
assert!(!is_tx_endpoint("/status"));
fn user_store_protects_last_admin() {
let directory = tempfile::tempdir().unwrap();
let config = AuthConfig::new(
true,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
Duration::from_secs(3600),
false,
SameSite::Lax,
);
let users = UserStore::open(&config).unwrap();
assert!(users.remove("admin").is_err());
assert!(users.update("admin", None, Some(AuthRole::User)).is_err());
}
#[actix_web::test]
async fn admin_endpoints_manage_multiple_users_and_reject_regular_users() {
let directory = tempfile::tempdir().unwrap();
let config = AuthConfig::new(
true,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
Duration::from_secs(3600),
false,
SameSite::Lax,
);
let state = web::Data::new(AuthState::new(config).unwrap());
let app = aw_test::init_service(
App::new()
.app_data(state)
.service(login)
.service(list_users)
.service(create_user)
.service(update_user)
.service(delete_user),
)
.await;
let request = aw_test::TestRequest::post()
.uri("/auth/login")
.set_json(serde_json::json!({"username":"admin","password":"password123"}))
.to_request();
let response = aw_test::call_service(&app, request).await;
assert_eq!(response.status(), actix_web::http::StatusCode::OK);
let admin_cookie = response.response().cookies().next().unwrap().to_string();
let request = aw_test::TestRequest::post()
.uri("/auth/users")
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
.set_json(
serde_json::json!({"username":"alice","password":"password456","role":"user"}),
)
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::CREATED
);
let request = aw_test::TestRequest::post()
.uri("/auth/login")
.set_json(serde_json::json!({"username":"alice","password":"password456"}))
.to_request();
let response = aw_test::call_service(&app, request).await;
let user_cookie = response.response().cookies().next().unwrap().to_string();
let request = aw_test::TestRequest::get()
.uri("/auth/users")
.insert_header((actix_web::http::header::COOKIE, user_cookie))
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::FORBIDDEN
);
let request = aw_test::TestRequest::patch()
.uri("/auth/users/alice")
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
.set_json(serde_json::json!({"password":"new-password","role":"admin"}))
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::OK
);
let request = aw_test::TestRequest::delete()
.uri("/auth/users/alice")
.insert_header((actix_web::http::header::COOKIE, admin_cookie))
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::OK
);
}
}
@@ -254,9 +254,9 @@ fn build_server(
};
let auth_config = AuthConfig::new(
context.http_auth.enabled,
context.http_auth.rx_passphrase.clone(),
context.http_auth.control_passphrase.clone(),
context.http_auth.tx_access_control_enabled,
context.http_auth.users_file.clone().into(),
context.http_auth.bootstrap_admin_username.clone(),
context.http_auth.bootstrap_admin_password.clone(),
Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure,
same_site,
@@ -273,7 +273,9 @@ fn build_server(
}
let context_data = web::Data::new(context);
let auth_state = web::Data::new(AuthState::new(auth_config.clone()));
let auth_state = web::Data::new(
AuthState::new(auth_config.clone()).map_err(actix_web::error::ErrorInternalServerError)?,
);
// Spawn session cleanup task if auth is enabled
if auth_config.enabled {
+77 -89
View File
@@ -268,18 +268,15 @@ impl AsRef<str> for CookieSameSite {
pub struct HttpAuthConfig {
/// Enable HTTP frontend authentication
pub enabled: bool,
/// Passphrase for read-only access (rx role)
pub rx_passphrase: Option<String>,
/// Read the rx passphrase from this file instead.
/// JSON file containing the managed user database.
pub users_file: String,
/// Username used to create the first administrator when the database is absent.
pub bootstrap_admin_username: Option<String>,
/// Password used to create the first administrator when the database is absent.
pub bootstrap_admin_password: Option<String>,
/// Read the bootstrap administrator password from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rx_passphrase_file: Option<String>,
/// Passphrase for full control access (control role)
pub control_passphrase: Option<String>,
/// Read the control passphrase from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_passphrase_file: Option<String>,
/// Enforce TX/PTT access control (hide from unauthenticated/rx users)
pub tx_access_control_enabled: bool,
pub bootstrap_admin_password_file: Option<String>,
/// Session time-to-live in minutes
pub session_ttl_min: u64,
/// Set Secure flag on session cookie (required for HTTPS)
@@ -292,11 +289,10 @@ impl Default for HttpAuthConfig {
fn default() -> Self {
Self {
enabled: false,
rx_passphrase: None,
rx_passphrase_file: None,
control_passphrase: None,
control_passphrase_file: None,
tx_access_control_enabled: true,
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: None,
bootstrap_admin_password: None,
bootstrap_admin_password_file: None,
session_ttl_min: 480,
cookie_secure: false,
cookie_same_site: CookieSameSite::Lax,
@@ -802,14 +798,9 @@ impl ClientConfig {
)?;
}
resolve_secret(
&mut self.frontends.http.auth.rx_passphrase,
&self.frontends.http.auth.rx_passphrase_file,
"[frontends.http.auth].rx_passphrase",
)?;
resolve_secret(
&mut self.frontends.http.auth.control_passphrase,
&self.frontends.http.auth.control_passphrase_file,
"[frontends.http.auth].control_passphrase",
&mut self.frontends.http.auth.bootstrap_admin_password,
&self.frontends.http.auth.bootstrap_admin_password_file,
"[frontends.http.auth].bootstrap_admin_password",
)?;
resolve_secret_list(
&mut self.frontends.http_json.auth.tokens,
@@ -819,7 +810,7 @@ impl ClientConfig {
if let Some(path) = config_path {
if self.has_inline_secrets() {
crate::secrets::warn_if_group_readable(path, "tokens/passphrases");
crate::secrets::warn_if_group_readable(path, "tokens/passwords");
}
}
Ok(())
@@ -832,10 +823,13 @@ impl ClientConfig {
.remotes
.iter()
.any(|r| r.auth.token_file.is_none() && r.auth.token.is_some())
|| self.frontends.http.auth.rx_passphrase_file.is_none()
&& self.frontends.http.auth.rx_passphrase.is_some()
|| self.frontends.http.auth.control_passphrase_file.is_none()
&& self.frontends.http.auth.control_passphrase.is_some()
|| self
.frontends
.http
.auth
.bootstrap_admin_password_file
.is_none()
&& self.frontends.http.auth.bootstrap_admin_password.is_some()
|| self.frontends.http_json.auth.tokens_file.is_none()
&& !self.frontends.http_json.auth.tokens.is_empty()
}
@@ -905,11 +899,10 @@ impl ClientConfig {
decode_history_retention_min_by_rig: HashMap::new(),
auth: HttpAuthConfig {
enabled: false,
rx_passphrase: Some("rx-passphrase-example".to_string()),
rx_passphrase_file: None,
control_passphrase: Some("control-passphrase-example".to_string()),
control_passphrase_file: None,
tx_access_control_enabled: true,
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: Some("admin".to_string()),
bootstrap_admin_password: Some("change-this-password".to_string()),
bootstrap_admin_password_file: None,
session_ttl_min: 480,
cookie_secure: false,
cookie_same_site: CookieSameSite::Lax,
@@ -948,29 +941,27 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
return Ok(());
}
// If enabled, require at least one passphrase
if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() {
if auth.users_file.trim().is_empty() {
return Err("[frontends.http.auth].users_file must not be empty".to_string());
}
if auth.bootstrap_admin_username.is_some() != auth.bootstrap_admin_password.is_some() {
return Err("[frontends.http.auth] bootstrap_admin_username and bootstrap_admin_password must be set together".to_string());
}
if auth
.bootstrap_admin_username
.as_deref()
.is_some_and(|v| v.trim().is_empty())
|| auth
.bootstrap_admin_password
.as_deref()
.is_some_and(|v| v.is_empty())
{
return Err(
"[frontends.http.auth] enabled=true requires at least one passphrase \
(rx_passphrase and/or control_passphrase)"
"[frontends.http.auth] bootstrap administrator credentials must not be empty"
.to_string(),
);
}
// Validate passphrases are not empty strings
if let Some(rx) = &auth.rx_passphrase {
if rx.trim().is_empty() {
return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string());
}
}
if let Some(ctrl) = &auth.control_passphrase {
if ctrl.trim().is_empty() {
return Err(
"[frontends.http.auth].control_passphrase must not be empty if set".to_string(),
);
}
}
// Session TTL must be > 0
if auth.session_ttl_min == 0 {
return Err("[frontends.http.auth].session_ttl_min must be > 0".to_string());
@@ -1238,42 +1229,34 @@ home-hf = "audio://10.0.0.5:4600"
}
#[test]
fn test_validate_rejects_http_auth_enabled_without_passphrases() {
fn test_validate_accepts_http_auth_with_user_database() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_bootstrap_admin_pair() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password = Some("secret-password".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_incomplete_bootstrap_admin_pair() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
assert!(config.validate().is_err());
}
#[test]
fn test_validate_accepts_http_auth_with_rx_passphrase() {
fn test_validate_rejects_empty_users_file() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_http_auth_with_control_passphrase() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.control_passphrase = Some("control-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_http_auth_with_both_passphrases() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
config.frontends.http.auth.control_passphrase = Some("control-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_empty_rx_passphrase() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("".to_string());
config.frontends.http.auth.users_file.clear();
assert!(config.validate().is_err());
}
@@ -1281,16 +1264,15 @@ home-hf = "audio://10.0.0.5:4600"
fn test_validate_rejects_zero_session_ttl() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
config.frontends.http.auth.session_ttl_min = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_validate_auth_disabled_ignores_passphrases() {
fn test_validate_auth_disabled_ignores_user_settings() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = false;
config.frontends.http.auth.rx_passphrase = Some("".to_string());
config.frontends.http.auth.users_file.clear();
assert!(config.validate().is_ok());
}
@@ -1298,9 +1280,9 @@ home-hf = "audio://10.0.0.5:4600"
fn test_http_auth_config_default() {
let auth = HttpAuthConfig::default();
assert!(!auth.enabled);
assert!(auth.rx_passphrase.is_none());
assert!(auth.control_passphrase.is_none());
assert!(auth.tx_access_control_enabled);
assert_eq!(auth.users_file, "trx-http-users.json");
assert!(auth.bootstrap_admin_username.is_none());
assert!(auth.bootstrap_admin_password.is_none());
assert_eq!(auth.session_ttl_min, 480);
assert!(!auth.cookie_secure);
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
@@ -1602,15 +1584,21 @@ spectrum_interval_ms = 0
}
#[test]
fn test_passphrase_file_fills_passphrase() {
fn test_bootstrap_password_file_fills_password() {
let f = secret_file("hunter2\n");
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.control_passphrase_file =
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password_file =
Some(f.path().to_str().unwrap().to_string());
config.resolve_secrets(None).unwrap();
assert_eq!(
config.frontends.http.auth.control_passphrase.as_deref(),
config
.frontends
.http
.auth
.bootstrap_admin_password
.as_deref(),
Some("hunter2")
);
assert!(config.validate().is_ok());
+2 -2
View File
@@ -111,8 +111,8 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
),
(
"trx-client.frontends.http.auth",
"Passphrase login for the web UI. rx_passphrase_file and\n\
control_passphrase_file keep the secrets out of this file.",
"Optional user/password ACL for the web UI. Administrators manage\n\
accounts stored in users_file.",
),
(
"trx-client.frontends.rigctl",