Refactor HTTP account system #61
Generated
+93
-5
@@ -316,6 +316,18 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -345,6 +357,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.66.1"
|
||||
@@ -395,6 +413,24 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.0"
|
||||
@@ -525,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
@@ -721,6 +757,15 @@ dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
@@ -739,6 +784,16 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.1"
|
||||
@@ -799,15 +854,26 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"crypto-common 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1019,6 +1085,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1890,6 +1966,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "peeking_take_while"
|
||||
version = "0.1.2"
|
||||
@@ -2492,8 +2579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3215,6 +3302,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"actix-ws",
|
||||
"argon2",
|
||||
"base64",
|
||||
"brotli 7.0.0",
|
||||
"bytes",
|
||||
|
||||
@@ -1043,7 +1043,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i
|
||||
|-----------|---------|------------|
|
||||
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` |
|
||||
| `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` |
|
||||
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `rx_passphrase`, `session_ttl_secs`, `tokens` |
|
||||
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin, `session_ttl_secs`, `tokens` |
|
||||
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` |
|
||||
| `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
|
||||
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
|
||||
|
||||
@@ -477,7 +477,7 @@ first wins.
|
||||
| `GET` | `/api/logbook/now` | The server's UTC clock, for checking the browser's |
|
||||
| `GET` | `/api/logbook/prefill` | The six fields an entry opens with |
|
||||
|
||||
Writes require the control role, as the rig endpoints do.
|
||||
Writes require the admin role, as the rig endpoints do.
|
||||
|
||||
### Frontend
|
||||
|
||||
@@ -516,7 +516,7 @@ All five are implemented.
|
||||
| Phase | Lands |
|
||||
|-------|-------|
|
||||
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers |
|
||||
| 2 | Store, dedupe, and the HTTP API behind the control role |
|
||||
| 2 | Store, dedupe, and the HTTP API behind the admin role |
|
||||
| 3 | Logbook tab: entry, table, filters, import, export |
|
||||
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
|
||||
| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics |
|
||||
@@ -541,7 +541,7 @@ setting, which is also what LoTW's station locations expect.
|
||||
rotate operators through one station callsign, which is why contest loggers record it per QSO.
|
||||
It is stored per QSO, defaulted from the configured callsign so a single operator never touches
|
||||
it, and changed on the station line at the top of the panel where it sticks for the session.
|
||||
It cannot be taken from the session's identity: the auth roles are `control` and `rx`, with no
|
||||
It cannot be taken from the session's identity: the auth roles are `admin` and `user`, with no
|
||||
notion of who is logged in.
|
||||
|
||||
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
|
||||
|
||||
+10
-11
@@ -121,13 +121,12 @@ The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers
|
||||
When auth is enabled, an **auth gate** blocks the UI with:
|
||||
|
||||
- Title: "Access Required"
|
||||
- Subtitle: "Enter passphrase to continue"
|
||||
- Password input + Login button (green accent, full-width)
|
||||
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
|
||||
- Subtitle: "Sign in to continue"
|
||||
- Username and password inputs + Login button (green accent, full-width)
|
||||
- Error message area (red `#ff6b6b`)
|
||||
- Role badge display
|
||||
|
||||
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
|
||||
Two roles: **User** (read-only) and **Admin** (full access including user management).
|
||||
|
||||
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
|
||||
|
||||
@@ -340,21 +339,21 @@ Routes are classified into three tiers:
|
||||
|
||||
| Tier | Examples | Requirement |
|
||||
|---|---|---|
|
||||
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
|
||||
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
|
||||
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
|
||||
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
|
||||
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | User or Admin role |
|
||||
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Admin role only |
|
||||
|
||||
### 7.2 Session Management
|
||||
|
||||
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
|
||||
- Configurable TTL (default from TOML config)
|
||||
- Expired sessions auto-pruned on access
|
||||
- Constant-time passphrase comparison to mitigate timing attacks
|
||||
- Passwords are verified against salted Argon2id hashes
|
||||
|
||||
### 7.3 TX Access Control
|
||||
### 7.3 User Management
|
||||
|
||||
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
|
||||
for Control-role users, providing an extra safety layer.
|
||||
Only administrators can list, add, update, or remove accounts. The final
|
||||
administrator cannot be removed or demoted.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+27
-23
@@ -66,8 +66,7 @@ both:
|
||||
|------------|----------|----------|
|
||||
| `[listen.auth].tokens` | `tokens_file` | one token per line |
|
||||
| `[[remotes]].auth.token` | `token_file` | the token |
|
||||
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
|
||||
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
|
||||
| `[frontends.http.auth].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
|
||||
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
|
||||
|
||||
Blank lines and `#` comments are ignored in the list files. A config that holds
|
||||
@@ -350,17 +349,17 @@ A name in any of those maps that no remote answers to is a config error.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Require a passphrase |
|
||||
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
|
||||
| `rx_passphrase_file` | string | — | Read it from this file instead |
|
||||
| `control_passphrase` | string | — | Passphrase granting full control |
|
||||
| `control_passphrase_file` | string | — | Read it from this file instead |
|
||||
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
|
||||
| `enabled` | bool | `false` | Enable the user/password ACL |
|
||||
| `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
|
||||
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
|
||||
| `bootstrap_admin_password` | string | — | First administrator password |
|
||||
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
|
||||
| `session_ttl_min` | u64 | `480` | Session lifetime |
|
||||
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
|
||||
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
|
||||
|
||||
With `enabled = true`, at least one passphrase must be set.
|
||||
When enabling ACL for the first time, configure both bootstrap fields. After
|
||||
the database exists, remove the bootstrap credentials from configuration.
|
||||
|
||||
#### `[frontends.rigctl]`
|
||||
|
||||
@@ -578,7 +577,7 @@ The link button in the top bar copies the current link to the clipboard. The
|
||||
address bar itself is updated as you tune, using `replaceState`, so sweeping
|
||||
the dial does not fill the browser's history.
|
||||
|
||||
Applying a link changes the radio, so it needs the `control` role; an `rx`
|
||||
Applying a link changes the radio, so it needs the `admin` role; a `user`
|
||||
session opens the page and says the link was not applied. Links describe the
|
||||
rig's own dial — while a tab is listening to a virtual channel the address is
|
||||
left as it was, rather than publishing a frequency the rig is not on.
|
||||
@@ -587,53 +586,58 @@ left as it was, rather than publishing a frequency the rig is not on.
|
||||
|
||||
## Authentication
|
||||
|
||||
The HTTP frontend supports optional passphrase-based authentication with two
|
||||
The HTTP frontend supports an optional user/password ACL with multiple accounts and two
|
||||
roles:
|
||||
|
||||
- **rx** — read-only access (monitoring, audio, decode streams)
|
||||
- **control** — full access (frequency, mode, PTT, and all settings)
|
||||
- **user** — read-only access (monitoring, audio, decode streams)
|
||||
- **admin** — full radio control, settings, and user management
|
||||
|
||||
### Configuration
|
||||
|
||||
```toml
|
||||
[frontends.http.auth]
|
||||
enabled = false
|
||||
rx_passphrase = "rx-only-passphrase"
|
||||
control_passphrase = "full-control-passphrase"
|
||||
tx_access_control_enabled = true
|
||||
users_file = "trx-http-users.json"
|
||||
bootstrap_admin_username = "admin"
|
||||
bootstrap_admin_password = "change-this-password"
|
||||
session_ttl_min = 480
|
||||
cookie_secure = false # true if served via HTTPS
|
||||
cookie_same_site = "Lax" # Strict|Lax|None
|
||||
```
|
||||
|
||||
When `enabled = false` (the default), all auth is bypassed and the UI behaves
|
||||
as before. When enabled, at least one passphrase must be set.
|
||||
as before. When enabling it for the first time, bootstrap credentials create
|
||||
the initial administrator and the Argon2id-hashed user database.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- On login, the server issues an `HttpOnly` session cookie.
|
||||
- Sessions are in-memory; a server restart invalidates all sessions.
|
||||
- Rate limiting is applied per IP to mitigate brute-force attempts.
|
||||
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
|
||||
rejected for unauthenticated or `rx`-role users.
|
||||
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
|
||||
- `user` sessions cannot call control routes. There is no guest-access mode.
|
||||
- Administrators can add/remove users and change roles/passwords in Settings.
|
||||
- Removing an account or changing its password/role revokes its sessions.
|
||||
|
||||
### Routes
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
|
||||
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
|
||||
| `/auth/logout` | POST | Clear session |
|
||||
| `/auth/session` | GET | Check current session/role |
|
||||
| `/auth/users` | GET/POST | List or add users (admin only) |
|
||||
| `/auth/users/{username}` | PATCH/DELETE | Change password/role or remove user (admin only) |
|
||||
|
||||
Protected routes require at least `rx` role. Control routes (set frequency,
|
||||
mode, PTT, etc.) require `control` role.
|
||||
Protected routes require at least `user` role. Control routes (set frequency,
|
||||
mode, PTT, etc.) require `admin` role.
|
||||
|
||||
### Frontend Flow
|
||||
|
||||
1. On load, the UI calls `/auth/session`.
|
||||
2. If unauthenticated, a login screen is shown.
|
||||
3. On successful login, the normal UI loads.
|
||||
4. `rx` users see a read-only interface; `control` users get full controls.
|
||||
4. `user` accounts see a read-only interface; admins get full controls.
|
||||
5. If a session expires mid-use, streams stop and the login screen returns.
|
||||
|
||||
### Transport Security
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -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());
|
||||
}
|
||||
let store = Self {
|
||||
path: config.users_file.clone(),
|
||||
users: RwLock::new(users),
|
||||
};
|
||||
if !store.path.exists() {
|
||||
store.persist()?;
|
||||
}
|
||||
None
|
||||
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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
+5
-5
@@ -203,13 +203,13 @@ decode_history_retention_min = 1440
|
||||
|
||||
[trx-client.frontends.http.decode_history_retention_min_by_rig]
|
||||
|
||||
# Passphrase login for the web UI. rx_passphrase_file and
|
||||
# control_passphrase_file keep the secrets out of this file.
|
||||
# Optional user/password ACL for the web UI. Administrators manage
|
||||
# accounts stored in users_file.
|
||||
[trx-client.frontends.http.auth]
|
||||
enabled = false
|
||||
rx_passphrase = "rx-passphrase-example"
|
||||
control_passphrase = "control-passphrase-example"
|
||||
tx_access_control_enabled = true
|
||||
users_file = "trx-http-users.json"
|
||||
bootstrap_admin_username = "admin"
|
||||
bootstrap_admin_password = "change-this-password"
|
||||
session_ttl_min = 480
|
||||
cookie_secure = false
|
||||
cookie_same_site = "Lax"
|
||||
|
||||
Reference in New Issue
Block a user