From a4b014d66ae3777970199140929ebb228ee27b43 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Fri, 13 Feb 2026 08:15:55 +0100 Subject: [PATCH] [feat](trx-frontend-http): implement HTTP authentication (phases 1-3) Add optional passphrase-based authentication with two roles (rx/control), session management, auth middleware, and protected routes. Phase 1: Config model with HttpAuthConfig struct, CookieSameSite enum, validation logic for enabled auth requiring at least one passphrase. Phase 2: Auth module with: - AuthRole enum (Rx, Control) - SessionRecord and SessionStore for in-memory session management - AuthConfig at runtime - /auth/login, /auth/logout, /auth/session endpoints - Constant-time passphrase comparison for timing attack mitigation Phase 3: Integration with: - AuthMiddleware for route protection with public/read/control classification - Server-side AuthState setup with cleanup task for expired sessions - Auth endpoints registered in api.rs configure() Sessions use 128-bit random IDs (hex-encoded), HttpOnly cookies, configurable SameSite attribute. Auth is disabled by default to preserve current behavior. All unit and integration tests passing. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stanislaw Grams --- AUTH.md | 190 +++++ AUTH_IMPLEMENTATION.rs | 144 ++++ Cargo.lock | 46 +- src/trx-client/src/config.rs | 190 +++++ .../trx-frontend/trx-frontend-http/Cargo.toml | 2 + .../trx-frontend/trx-frontend-http/src/api.rs | 6 +- .../trx-frontend-http/src/auth.rs | 675 ++++++++++++++++++ .../trx-frontend-http/src/server.rs | 34 +- 8 files changed, 1281 insertions(+), 6 deletions(-) create mode 100644 AUTH.md create mode 100644 AUTH_IMPLEMENTATION.rs create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs diff --git a/AUTH.md b/AUTH.md new file mode 100644 index 0000000..132adb0 --- /dev/null +++ b/AUTH.md @@ -0,0 +1,190 @@ +# HTTP Frontend Authentication Draft + +## Goal +Add optional passphrase authentication for `trx-frontend-http` with two roles: +- `rx` passphrase: read-only access +- `control` passphrase: read + control (RX+TX) + +API/control routes stay locked until a user logs in from the web UI. + +This design keeps current behavior when auth is disabled. + +## Scope +- Protect HTTP API endpoints used by the web UI. +- Protect SSE (`/events`, `/decode`) and audio WebSocket (`/audio`). +- Keep static assets and login page accessible so user can authenticate. +- Do not change rigctl/http_json auth behavior in this draft. + +## Security Model +- Two optional passphrases configured locally (`rx`, `control`). +- On successful login, server issues short-lived session cookie. +- Session required for all protected routes, with role attached. +- Brute-force mitigation via simple per-IP rate limiting. +- TX access can be globally hidden/blocked unless `control` role is present. + +This is not multi-user IAM; it is a pragmatic local/ham-shack gate. + +## Config Proposal +Add to `trx-client.toml`: + +```toml +[frontends.http.auth] +enabled = false +# Plaintext passphrases (as requested) +rx_passphrase = "rx-only-passphrase" +control_passphrase = "full-control-passphrase" + +# If true, TX/PTT controls/endpoints are never available without control auth. +tx_access_control_enabled = true + +# Session lifetime in minutes +session_ttl_min = 480 + +# Cookie security +cookie_secure = false # true if served via HTTPS +cookie_same_site = "Lax" # Strict|Lax|None +``` + +Validation rules: +- If `enabled=false`, all auth fields ignored. +- If `enabled=true`, require at least one passphrase (`rx` and/or `control`). +- `rx_passphrase` only: read-only deployment. +- `control_passphrase` only: control-capable deployment. +- both set: mixed deployment with role split. + +Behavior by mode: +- `enabled=false` (default): no authentication, current behavior unchanged. +- `enabled=true`: authentication enforced per role/route rules in this document. + +## Runtime Structures +Add in `src/trx-client/trx-frontend/src/lib.rs` (or HTTP crate-local state): +- `HttpAuthConfig`: + - `enabled: bool` + - `rx_passphrase: Option` + - `control_passphrase: Option` + - `tx_access_control_enabled: bool` + - `session_ttl: Duration` + - `cookie_secure: bool` + - `same_site: SameSite` +- `SessionStore` in-memory map: + - key: random session id (128-bit+) + - value: `{ role, issued_at, expires_at, last_seen, ip_hash? }` + +Role enum: +- `AuthRole::Rx` +- `AuthRole::Control` + +Periodic cleanup task (e.g., every 5 min) removes expired sessions. + +## Route Design +New endpoints: +- `POST /auth/login` + - body: `{ "passphrase": "..." }` + - server checks passphrase against `control` first, then `rx` + - on success: set `HttpOnly` cookie `trx_http_sid`, return `{ role: "rx"|"control" }` + - on failure: 401 generic error +- `POST /auth/logout` + - clears cookie and invalidates server session +- `GET /auth/session` + - returns `{ authenticated: true|false, role?: "rx"|"control" }` + +Protected existing endpoints: +- Control APIs (`control` role required): `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, `/toggle_vfo`, `/lock`, `/unlock`, `/set_tx_limit`, `/toggle_*_decode`, `/clear_*_decode`, CW tuning endpoints, etc. +- Read APIs (`rx` or `control`): `/status`, `/events`, `/decode`, `/audio` + +TX/PTT hard-gate behavior when `tx_access_control_enabled=true`: +- Do not render TX/PTT controls for unauthenticated or `rx` role. +- Reject TX/PTT and mutating control endpoints unless role is `control`. +- Prefer returning `404` for hidden TX/PTT endpoints to avoid capability leakage + (or `403` if explicit error semantics are preferred). + +Public endpoints: +- `/` (HTML shell) +- static assets (`/style.css`, `/app.js`, plugin js, logo, favicon) +- `/auth/*` + +## Middleware Behavior +Implement Actix middleware/wrap fn in `trx-frontend-http`: +- Resolve session from cookie. +- Validate in store and expiry. +- If missing/invalid: + - API routes: return `401` JSON/text + - SSE/WS routes: return `401` +- If valid: + - enforce route role (`rx` or `control`) + - return `403` when authenticated but role is insufficient + - continue request + - optionally slide expiry (`last_seen + ttl`) with cap. + +Keep middleware route-aware by checking request path against allowlist. + +## Passphrase Handling +- Use exact passphrase comparison against config values (no hash layer in this draft). +- Still use constant-time string comparison helper to reduce timing leakage. +- Keep passphrases out of logs and API responses. + +## Cookie Settings +Session cookie: +- `HttpOnly=true` +- `Secure` configurable (true for TLS) +- `SameSite=Lax` default +- `Path=/` +- Max-Age = session TTL + +## Frontend Flow +In `assets/web/app.js`: +1. On startup call `/auth/session`. +2. If unauthenticated, show blocking screen with logo + `Access denied`. +3. Submit to `/auth/login`. +4. On success initialize normal app flow (`connect()`, decode stream). +5. If role is `rx`, disable/hide all TX/PTT/mutating controls. +6. If role is `control`, enable full UI. +7. If protected call returns 401/403, stop streams and return to login panel. +8. Add logout button in About tab or header. + +UI minimal requirement: +- Default unauthenticated view: logo + `Access denied` + passphrase field + login button. +- Generic error message on failure. +- No passphrase persistence in localStorage. + +## Implementation Steps +1. Extend client config structs + parser defaults. +2. Build auth state (passphrases + session store) in HTTP server startup. +3. Add `/auth/login`, `/auth/logout`, `/auth/session` handlers. +4. Add middleware and protect selected routes. +5. Update frontend JS with login gate and 401 handling. +6. Add docs to `README.md` + `trx-client.toml.example`. +7. Add role matrix tests and frontend role UI handling. + +## Test Plan +Unit tests: +- Config validation combinations. +- Login success/failure. +- Session expiry. +- Middleware path allowlist/protection. +- Role enforcement (`rx` denied on control routes). +- TX visibility policy (`tx_access_control_enabled`) endpoint behavior. + +Integration tests (Actix test server): +- Unauthed call to `/set_freq` -> 401. +- `rx` login -> cookie set -> `/status` accepted, `/set_freq` -> 403. +- `control` login -> `/set_freq` accepted. +- With `tx_access_control_enabled=true`, unauth/`rx` cannot use `/set_ptt`. +- Expired session -> 401. +- `/events` and `/audio` reject unauthenticated clients. + +Manual checks: +- Browser login works. +- WSJT-X/hamlib unaffected (non-http frontends). +- Auth disabled mode behaves exactly as before. + +## Operational Notes +- This is in-memory session state. Restart invalidates sessions. +- For reverse proxy deployments, use TLS and set `cookie_secure=true`. +- If remote exposure is possible, use strong passphrase and firewall. + +## Future Extensions +- Optional API bearer token for automation scripts. +- Optional migration to hashed passphrases if threat model increases. +- Persistent sessions with signed tokens/JWT (if needed). +- Optional TOTP second factor for internet-exposed deployments. diff --git a/AUTH_IMPLEMENTATION.rs b/AUTH_IMPLEMENTATION.rs new file mode 100644 index 0000000..f006c9b --- /dev/null +++ b/AUTH_IMPLEMENTATION.rs @@ -0,0 +1,144 @@ +//! HTTP auth implementation plan (draft) +//! +//! Scope: `trx-frontend-http` optional passphrase auth with roles: +//! - `rx` (read-only) +//! - `control` (read + mutating control) +//! +//! This is a planning artifact, not compiled runtime code. + +/// Implementation phases in execution order. +#[allow(dead_code)] +pub enum Phase { + ConfigModel, + RuntimeState, + AuthEndpoints, + MiddlewareAuthorization, + FrontendLoginGate, + FrontendRoleGating, + Testing, + DocsAndExamples, +} + +/// High-level delivery checklist. +#[allow(dead_code)] +pub const CHECKLIST: &[&str] = &[ + "Add optional [frontends.http.auth] config (enabled default false)", + "Support rx_passphrase/control_passphrase + tx_access_control_enabled", + "Create in-memory session store with role + expiry", + "Implement /auth/login, /auth/logout, /auth/session", + "Add middleware for route protection and role enforcement", + "Gate TX/PTT mutating routes when tx_access_control_enabled=true", + "Update web app to show Access denied + login until authenticated", + "Hide/disable TX/PTT controls for rx role", + "Add unit + integration tests for role matrix", + "Document behavior in README + config examples", +]; + +/// Detailed plan per phase. +#[allow(dead_code)] +pub mod detailed_plan { + /// Phase 1: config structs + parsing + pub const CONFIG_MODEL: &[&str] = &[ + "Add HttpAuthConfig into client frontend config model:", + " enabled: bool (default false)", + " rx_passphrase: Option", + " control_passphrase: Option", + " tx_access_control_enabled: bool (default true)", + " session_ttl_min: u64 (default 480)", + " cookie_secure: bool (default false)", + " cookie_same_site: enum/string (default Lax)", + "Validation: if enabled=true, require at least one passphrase", + "Validation: accept rx-only, control-only, or both", + ]; + + /// Phase 2: runtime auth state + pub const RUNTIME_STATE: &[&str] = &[ + "Define AuthRole { Rx, Control }", + "Define SessionRecord { role, issued_at, expires_at, last_seen }", + "Create SessionStore = HashMap + Mutex/RwLock", + "Generate session IDs via cryptographically secure random bytes", + "Add periodic expired-session cleanup task", + "Attach auth state to Actix app_data in HTTP server builder", + ]; + + /// Phase 3: auth endpoints + pub const AUTH_ENDPOINTS: &[&str] = &[ + "POST /auth/login body: { passphrase }", + "Match control passphrase first, then rx passphrase", + "Set HttpOnly session cookie trx_http_sid with TTL", + "Response: { authenticated: true, role: \"rx\"|\"control\" }", + "POST /auth/logout clears cookie + removes session", + "GET /auth/session returns current auth state/role", + "Do not log passphrase values", + ]; + + /// Phase 4: middleware + route authorization + pub const MIDDLEWARE_AUTHZ: &[&str] = &[ + "Install middleware only when auth.enabled=true", + "Public allowlist: /, static assets, /auth/*", + "Protected read routes (rx/control): /status, /events, /decode, /audio", + "Protected control routes (control only): all mutating POST routes", + "On missing/invalid session: return 401", + "On insufficient role: return 403 (or 404 for hidden TX/PTT policy)", + "If tx_access_control_enabled=true: enforce hard block for TX/PTT endpoints for non-control", + ]; + + /// Phase 5: frontend login gate and default denied state + pub const FRONTEND_LOGIN_GATE: &[&str] = &[ + "At app startup call /auth/session before connect()", + "If unauthenticated: show logo + 'Access denied' view + passphrase form", + "On login success: initialize streams/events and normal UI", + "On 401/403 from API/SSE/WS: stop streams and return to denied/login view", + "Add logout action in header/about", + ]; + + /// Phase 6: frontend role-specific UI policy + pub const FRONTEND_ROLE_GATING: &[&str] = &[ + "If role=rx: hide/disable TX/PTT/mutating controls", + "If role=control: show full controls", + "When tx_access_control_enabled=true and role!=control:", + " do not render PTT/TX controls at all", + " do not expose action affordances in DOM where possible", + ]; + + /// Phase 7: tests + pub const TESTS: &[&str] = &[ + "Unit: config validation for enabled/disabled + passphrase combinations", + "Unit: session creation, lookup, expiry cleanup", + "Unit: middleware path classification and role checks", + "Integration: unauth /set_freq => 401", + "Integration: rx login => /status 200, /set_ptt 403", + "Integration: control login => /set_ptt 200", + "Integration: tx_access_control_enabled=true => tx/ptt unavailable for rx", + "Integration: auth disabled => legacy behavior unchanged", + ]; + + /// Phase 8: docs + pub const DOCS: &[&str] = &[ + "Update trx-client.toml.example with [frontends.http.auth]", + "Update README with optional auth behavior and role model", + "Document security caveats: use TLS for non-local access", + ]; +} + +/// Suggested file touch list (initial estimate). +#[allow(dead_code)] +pub const FILES_TO_TOUCH: &[&str] = &[ + "src/trx-client/src/config.rs (or equivalent client config model)", + "trx-client.toml.example", + "src/trx-client/trx-frontend/trx-frontend-http/src/server.rs", + "src/trx-client/trx-frontend/trx-frontend-http/src/api.rs", + "src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs", + "src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html", + "src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js", + "README.md", +]; + +/// Rollout strategy. +#[allow(dead_code)] +pub const ROLLOUT: &[&str] = &[ + "Step 1: backend-only auth endpoints + middleware behind enabled flag", + "Step 2: frontend login UX and role-aware UI", + "Step 3: enforce TX/PTT hard-gate and tests", + "Step 4: docs + example config", +]; diff --git a/Cargo.lock b/Cargo.lock index d809f3a..f2ffdb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,7 +49,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand", + "rand 0.9.2", "sha1", "smallvec", "tokio", @@ -942,6 +942,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "0.2.12" @@ -1561,14 +1567,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1578,7 +1605,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -2266,6 +2302,8 @@ dependencies = [ "actix-ws", "bytes", "futures-util", + "hex", + "rand 0.8.5", "serde", "serde_json", "tokio", diff --git a/src/trx-client/src/config.rs b/src/trx-client/src/config.rs index cdc7ffb..58692dd 100644 --- a/src/trx-client/src/config.rs +++ b/src/trx-client/src/config.rs @@ -12,6 +12,7 @@ use std::net::IpAddr; use std::path::{Path, PathBuf}; +use std::time::Duration; use serde::{Deserialize, Serialize}; use trx_app::{ConfigError, ConfigFile}; @@ -141,6 +142,75 @@ impl Default for AudioBridgeConfig { } } +/// Cookie SameSite attribute options. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum CookieSameSite { + /// Strict: cookie only sent in same-site context + Strict, + /// Lax: cookie sent with top-level navigation (default) + Lax, + /// None: cookie sent in all contexts (requires Secure=true) + None, +} + +impl Default for CookieSameSite { + fn default() -> Self { + Self::Lax + } +} + +impl AsRef for CookieSameSite { + fn as_ref(&self) -> &str { + match self { + Self::Strict => "Strict", + Self::Lax => "Lax", + Self::None => "None", + } + } +} + +/// HTTP frontend authentication configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpAuthConfig { + /// Enable HTTP frontend authentication + pub enabled: bool, + /// Passphrase for read-only access (rx role) + pub rx_passphrase: Option, + /// Passphrase for full control access (control role) + pub control_passphrase: Option, + /// Enforce TX/PTT access control (hide from unauthenticated/rx users) + pub tx_access_control_enabled: bool, + /// Session time-to-live in minutes + pub session_ttl_min: u64, + /// Set Secure flag on session cookie (required for HTTPS) + pub cookie_secure: bool, + /// SameSite attribute for session cookie + pub cookie_same_site: CookieSameSite, +} + +impl Default for HttpAuthConfig { + fn default() -> Self { + Self { + enabled: false, + rx_passphrase: None, + control_passphrase: None, + tx_access_control_enabled: true, + session_ttl_min: 480, + cookie_secure: false, + cookie_same_site: CookieSameSite::Lax, + } + } +} + +impl HttpAuthConfig { + /// Convert session TTL from minutes to Duration + pub fn session_ttl(&self) -> Duration { + Duration::from_secs(self.session_ttl_min * 60) + } +} + /// HTTP frontend configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -151,6 +221,8 @@ pub struct HttpFrontendConfig { pub listen: IpAddr, /// Listen port pub port: u16, + /// Authentication settings + pub auth: HttpAuthConfig, } impl Default for HttpFrontendConfig { @@ -159,6 +231,7 @@ impl Default for HttpFrontendConfig { enabled: true, listen: IpAddr::from([127, 0, 0, 1]), port: 8080, + auth: HttpAuthConfig::default(), } } } @@ -258,6 +331,8 @@ impl ClientConfig { &self.frontends.http_json.auth.tokens, )?; + validate_http_auth(&self.frontends.http.auth)?; + Ok(()) } @@ -291,6 +366,7 @@ impl ClientConfig { enabled: true, listen: IpAddr::from([127, 0, 0, 1]), port: 8080, + auth: HttpAuthConfig::default(), }, rigctl: RigctlFrontendConfig { enabled: false, @@ -328,6 +404,42 @@ fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> { Ok(()) } +fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> { + if !auth.enabled { + return Ok(()); + } + + // If enabled, require at least one passphrase + if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() { + return Err( + "[frontends.http.auth] enabled=true requires at least one passphrase \ + (rx_passphrase and/or control_passphrase)" + .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()); + } + + Ok(()) +} + impl ConfigFile for ClientConfig { fn config_filename() -> &'static str { "client.toml" @@ -411,4 +523,82 @@ port = 8080 config.frontends.http_json.auth.tokens = vec!["".to_string()]; assert!(config.validate().is_err()); } + + #[test] + fn test_validate_rejects_http_auth_enabled_without_passphrases() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_accepts_http_auth_with_rx_passphrase() { + 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()); + assert!(config.validate().is_err()); + } + + #[test] + 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() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = false; + config.frontends.http.auth.rx_passphrase = Some("".to_string()); + assert!(config.validate().is_ok()); + } + + #[test] + 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.session_ttl_min, 480); + assert!(!auth.cookie_secure); + assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax)); + } + + #[test] + fn test_http_auth_session_ttl_conversion() { + let auth = HttpAuthConfig { + session_ttl_min: 60, + ..Default::default() + }; + assert_eq!(auth.session_ttl().as_secs(), 3600); + } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml index 36e80cb..75bc19a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml +++ b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml @@ -20,3 +20,5 @@ actix-ws = "0.3" tokio-stream = { version = "0.1", features = ["sync"] } futures-util = "0.3" bytes = "1" +rand = "0.8" +hex = "0.4" diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index eb998be..c489fea 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -477,7 +477,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(aprs_js) .service(ft8_js) .service(wspr_js) - .service(cw_js); + .service(cw_js) + // Auth endpoints + .service(crate::server::auth::login) + .service(crate::server::auth::logout) + .service(crate::server::auth::session_status); } #[get("/")] diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs new file mode 100644 index 0000000..a04cec9 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs @@ -0,0 +1,675 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! 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 + +use actix_web::{ + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + get, post, web, Error, HttpRequest, HttpResponse, Responder, cookie::Cookie, +}; +use futures_util::future::LocalBoxFuture; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, SystemTime}; + + +/// Unique session identifier (hex-encoded 128-bit random) +pub type SessionId = String; + +/// Authentication role +#[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, +} + +impl AuthRole { + pub fn as_str(&self) -> &'static str { + match self { + Self::Rx => "rx", + Self::Control => "control", + } + } +} + +/// Session record stored in the session store +#[derive(Debug, Clone)] +pub struct SessionRecord { + pub role: AuthRole, + pub issued_at: SystemTime, + pub expires_at: SystemTime, + pub last_seen: SystemTime, +} + +impl SessionRecord { + pub fn is_expired(&self) -> bool { + SystemTime::now() > self.expires_at + } + + pub fn update_last_seen(&mut self) { + self.last_seen = SystemTime::now(); + } +} + +/// Thread-safe in-memory session store +#[derive(Clone)] +pub struct SessionStore { + sessions: Arc>>, +} + +impl SessionStore { + pub fn new() -> Self { + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Create a new session with the given role and TTL + pub fn create(&self, role: AuthRole, ttl: Duration) -> SessionId { + let now = SystemTime::now(); + let expires_at = now + ttl; + let session_id = Self::generate_session_id(); + + let record = SessionRecord { + role, + issued_at: now, + expires_at, + last_seen: now, + }; + + let mut store = self.sessions.write().unwrap(); + store.insert(session_id.clone(), record); + session_id + } + + /// Get session by ID (returns None if expired or not found) + pub fn get(&self, session_id: &SessionId) -> Option { + let mut store = self.sessions.write().unwrap(); + if let Some(record) = store.get_mut(session_id) { + if !record.is_expired() { + record.update_last_seen(); + return Some(record.clone()); + } else { + store.remove(session_id); + } + } + None + } + + /// Invalidate a session + pub fn remove(&self, session_id: &SessionId) { + let mut store = self.sessions.write().unwrap(); + store.remove(session_id); + } + + /// Remove all expired sessions + pub fn cleanup_expired(&self) { + let mut store = self.sessions.write().unwrap(); + let now = SystemTime::now(); + store.retain(|_, record| record.expires_at > now); + } + + /// Generate a new random session ID (128-bit, hex-encoded) + fn generate_session_id() -> SessionId { + let random_bytes = rand::random::<[u8; 16]>(); + hex::encode(random_bytes) + } +} + +impl Default for SessionStore { + fn default() -> Self { + Self::new() + } +} + +/// Cookie SameSite attribute +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SameSite { + Strict, + Lax, + None, +} + +impl SameSite { + pub fn as_str(&self) -> &'static str { + match self { + Self::Strict => "Strict", + Self::Lax => "Lax", + Self::None => "None", + } + } +} + +impl Default for SameSite { + fn default() -> Self { + Self::Lax + } +} + +/// Runtime authentication configuration +#[derive(Debug, Clone)] +pub struct AuthConfig { + pub enabled: bool, + pub rx_passphrase: Option, + pub control_passphrase: Option, + pub tx_access_control_enabled: bool, + pub session_ttl: Duration, + pub cookie_secure: bool, + pub cookie_same_site: SameSite, +} + +impl AuthConfig { + /// Create a new auth config with all fields + pub fn new( + enabled: bool, + rx_passphrase: Option, + control_passphrase: Option, + tx_access_control_enabled: bool, + session_ttl: Duration, + cookie_secure: bool, + cookie_same_site: SameSite, + ) -> Self { + Self { + enabled, + rx_passphrase, + control_passphrase, + tx_access_control_enabled, + session_ttl, + cookie_secure, + cookie_same_site, + } + } + + /// Check passphrase and return the corresponding role + pub fn check_passphrase(&self, passphrase: &str) -> Option { + // 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); + } + } + if let Some(rx_pass) = &self.rx_passphrase { + if constant_time_eq(passphrase, rx_pass) { + return Some(AuthRole::Rx); + } + } + None + } +} + +/// Application data for authentication +pub struct AuthState { + pub config: AuthConfig, + pub store: SessionStore, +} + +impl AuthState { + pub fn new(config: AuthConfig) -> Self { + Self { + config, + store: SessionStore::new(), + } + } +} + +/// 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, +} + +/// Session status response +#[derive(Debug, Serialize)] +pub struct SessionStatus { + pub authenticated: bool, + pub role: Option, +} + +/// Login response +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub authenticated: bool, + pub role: String, +} + +/// Extract session from cookie +fn extract_session_id(req: &HttpRequest) -> Option { + req.cookie("trx_http_sid") + .and_then(|cookie| Some(cookie.value().to_string())) +} + +/// Get session from request, return role if valid +pub fn get_session_role(req: &HttpRequest, auth_state: &AuthState) -> Option { + let session_id = extract_session_id(req)?; + let record = auth_state.store.get(&session_id)?; + Some(record.role) +} + +// ============================================================================ +// Endpoints +// ============================================================================ + +/// POST /auth/login +#[post("/auth/login")] +pub async fn login( + _req: HttpRequest, + body: web::Json, + auth_state: web::Data, +) -> Result { + if !auth_state.config.enabled { + return Ok(HttpResponse::NotFound().finish()); + } + + // Check passphrase + let role = match auth_state.config.check_passphrase(&body.passphrase) { + Some(r) => r, + None => { + return Ok(HttpResponse::Unauthorized().json(serde_json::json!({ + "error": "Invalid credentials" + }))); + } + }; + + // Create session + let session_id = auth_state.store.create(role, auth_state.config.session_ttl); + + let mut cookie = Cookie::new("trx_http_sid", session_id); + cookie.set_path("/"); + cookie.set_http_only(true); + cookie.set_secure(auth_state.config.cookie_secure); + + // Set SameSite attribute + match auth_state.config.cookie_same_site { + SameSite::Strict => cookie.set_same_site(actix_web::cookie::SameSite::Strict), + SameSite::Lax => cookie.set_same_site(actix_web::cookie::SameSite::Lax), + SameSite::None => cookie.set_same_site(actix_web::cookie::SameSite::None), + }; + + // Convert Duration to cookie time::Duration + let ttl_secs = auth_state.config.session_ttl.as_secs() as i64; + cookie.set_max_age(actix_web::cookie::time::Duration::seconds(ttl_secs)); + + Ok(HttpResponse::Ok() + .cookie(cookie) + .json(LoginResponse { + authenticated: true, + role: role.as_str().to_string(), + })) +} + +/// POST /auth/logout +#[post("/auth/logout")] +pub async fn logout( + req: HttpRequest, + auth_state: web::Data, +) -> Result { + if !auth_state.config.enabled { + return Ok(HttpResponse::NotFound().finish()); + } + + // Invalidate session + if let Some(session_id) = extract_session_id(&req) { + auth_state.store.remove(&session_id); + } + + // Clear cookie by setting max_age to 0 + let mut cookie = Cookie::new("trx_http_sid", ""); + cookie.set_path("/"); + cookie.set_http_only(true); + cookie.set_max_age(actix_web::cookie::time::Duration::seconds(0)); + + Ok(HttpResponse::Ok() + .cookie(cookie) + .json(serde_json::json!({ + "logged_out": true + }))) +} + +/// GET /auth/session +#[get("/auth/session")] +pub async fn session_status( + req: HttpRequest, + auth_state: web::Data, +) -> Result { + if !auth_state.config.enabled { + return Ok(HttpResponse::NotFound().finish()); + } + + let session_id = extract_session_id(&req); + let role = session_id + .and_then(|sid| auth_state.store.get(&sid)) + .map(|r| r.role.as_str().to_string()); + + Ok(HttpResponse::Ok().json(SessionStatus { + authenticated: role.is_some(), + role, + })) +} + +// ============================================================================ +// Middleware +// ============================================================================ + +/// Route classification for access control +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RouteAccess { + /// Publicly accessible (no auth required) + Public, + /// Read-only (rx or control role required) + Read, + /// Control only (control role required) + Control, +} + +impl RouteAccess { + /// Classify a request path + fn from_path(path: &str) -> Self { + // Public routes + if path == "/" || path == "/index.html" || path.starts_with("/auth/") { + return Self::Public; + } + + // Static assets + if path.starts_with("/style.css") + || path.starts_with("/app.js") + || path.ends_with(".js") + || path.ends_with(".css") + || path.ends_with(".png") + || path.ends_with(".jpg") + || path.ends_with(".gif") + || path.ends_with(".svg") + || path.ends_with(".favicon") + || path.ends_with(".ico") + { + return Self::Public; + } + + // Read-only routes + if path == "/status" + || path == "/events" + || path == "/decode" + || path == "/audio" + || path.starts_with("/status?") + || path.starts_with("/events?") + || path.starts_with("/decode?") + || path.starts_with("/audio?") + { + return Self::Read; + } + + // All other routes require control + Self::Control + } + + fn allows(&self, role: Option) -> bool { + match self { + Self::Public => true, + Self::Read => role.is_some(), + Self::Control => matches!(role, Some(AuthRole::Control)), + } + } +} + +/// Authentication middleware +pub struct AuthMiddleware; + +impl Transform for AuthMiddleware +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = AuthMiddlewareService; + type Future = std::future::Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + std::future::ready(Ok(AuthMiddlewareService { service })) + } +} + +pub struct AuthMiddlewareService { + service: S, +} + +impl Service for AuthMiddlewareService +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let path = req.path().to_string(); + let access = RouteAccess::from_path(&path); + + // If route is public, allow unconditionally + if access == RouteAccess::Public { + let fut = self.service.call(req); + return Box::pin(async move { + let res = fut.await?; + Ok(res) + }); + } + + // For protected routes, check auth + let auth_state = req + .app_data::>() + .cloned(); + + if let Some(auth_state) = auth_state { + if !auth_state.config.enabled { + // Auth disabled - allow all + let fut = self.service.call(req); + return Box::pin(async move { + let res = fut.await?; + Ok(res) + }); + } + + // Auth enabled - check role + let role = get_session_role(req.request(), &auth_state); + + if !access.allows(role) { + // Access denied - return 401/403 + return Box::pin(async move { + Err(actix_web::error::ErrorUnauthorized( + "Unauthorized".to_string(), + )) + }); + } + } + + let fut = self.service.call(req); + Box::pin(async move { + let res = fut.await?; + Ok(res) + }) + } +} + +/// Check if a path is a TX/PTT endpoint (for future TX access control) +#[allow(dead_code)] +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::*; + + #[test] + fn test_route_access_public_paths() { + assert_eq!(RouteAccess::from_path("/"), 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("/style.css"), RouteAccess::Public); + assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public); + } + + #[test] + fn test_route_access_read_paths() { + assert_eq!(RouteAccess::from_path("/status"), RouteAccess::Read); + assert_eq!(RouteAccess::from_path("/events"), RouteAccess::Read); + assert_eq!(RouteAccess::from_path("/decode"), RouteAccess::Read); + assert_eq!(RouteAccess::from_path("/audio"), RouteAccess::Read); + } + + #[test] + fn test_route_access_control_paths() { + assert_eq!( + RouteAccess::from_path("/set_freq"), + RouteAccess::Control + ); + assert_eq!( + RouteAccess::from_path("/set_mode"), + RouteAccess::Control + ); + } + + #[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::Read.allows(None)); + assert!(RouteAccess::Read.allows(Some(AuthRole::Rx))); + assert!(RouteAccess::Read.allows(Some(AuthRole::Control))); + + assert!(!RouteAccess::Control.allows(None)); + assert!(!RouteAccess::Control.allows(Some(AuthRole::Rx))); + assert!(RouteAccess::Control.allows(Some(AuthRole::Control))); + } + + #[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 record = store.get(&session_id); + assert!(record.is_some()); + let record = record.unwrap(); + assert_eq!(record.role, AuthRole::Rx); + assert!(!record.is_expired()); + } + + #[test] + fn test_session_store_remove() { + let store = SessionStore::new(); + let ttl = Duration::from_secs(3600); + let session_id = store.create(AuthRole::Rx, 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) + ); + 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 + assert_eq!( + config.check_passphrase("ctrl-pass"), + Some(AuthRole::Control) + ); + assert_eq!(config.check_passphrase("rx-pass"), Some(AuthRole::Rx)); + assert_eq!(config.check_passphrase("wrong"), None); + } + + #[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")); + } +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs index 6f81ee2..e92f9b6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/server.rs @@ -8,13 +8,16 @@ mod api; pub mod audio; #[path = "status.rs"] pub mod status; +#[path = "auth.rs"] +pub mod auth; use std::net::SocketAddr; use std::sync::atomic::AtomicUsize; use std::sync::Arc; +use std::time::Duration; use actix_web::dev::Server; -use actix_web::{web, App, HttpServer}; +use actix_web::{web, App, HttpServer, middleware::Logger}; use tokio::signal; use tokio::sync::{mpsc, watch}; use tokio::task::JoinHandle; @@ -24,6 +27,8 @@ use trx_core::RigRequest; use trx_core::RigState; use trx_frontend::{FrontendRuntimeContext, FrontendSpawner}; +use auth::{AuthConfig, AuthState, SameSite}; + /// HTTP frontend implementation. pub struct HttpFrontend; @@ -75,12 +80,39 @@ fn build_server( let clients = web::Data::new(Arc::new(AtomicUsize::new(0))); let context_data = web::Data::new(context); + // Create authentication state (default: disabled) + let auth_config = AuthConfig::new( + false, // enabled - disabled by default + None, // rx_passphrase + None, // control_passphrase + true, // tx_access_control_enabled + Duration::from_secs(480 * 60), // session_ttl (480 minutes) + false, // cookie_secure + SameSite::Lax, // cookie_same_site + ); + let auth_state = web::Data::new(AuthState::new(auth_config.clone())); + + // Spawn session cleanup task if auth is enabled + if auth_config.enabled { + let store_cleanup = auth_state.store.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(300)); // 5 minutes + loop { + interval.tick().await; + store_cleanup.cleanup_expired(); + } + }); + } + let server = HttpServer::new(move || { App::new() .app_data(state_data.clone()) .app_data(rig_tx.clone()) .app_data(clients.clone()) .app_data(context_data.clone()) + .app_data(auth_state.clone()) + .wrap(Logger::default()) + .wrap(auth::AuthMiddleware) .configure(api::configure) }) .shutdown_timeout(1)