[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 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -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<String>`
|
||||
- `control_passphrase: Option<String>`
|
||||
- `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.
|
||||
@@ -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<String>",
|
||||
" control_passphrase: Option<String>",
|
||||
" 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<SessionId, SessionRecord> + 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",
|
||||
];
|
||||
Generated
+42
-4
@@ -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",
|
||||
|
||||
@@ -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<str> 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<String>,
|
||||
/// Passphrase for full control access (control role)
|
||||
pub control_passphrase: Option<String>,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("/")]
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// 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<RwLock<HashMap<SessionId, SessionRecord>>>,
|
||||
}
|
||||
|
||||
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<SessionRecord> {
|
||||
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<String>,
|
||||
pub control_passphrase: Option<String>,
|
||||
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<String>,
|
||||
control_passphrase: Option<String>,
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<SessionId> {
|
||||
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<AuthRole> {
|
||||
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<LoginRequest>,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> Result<impl Responder, Error> {
|
||||
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<AuthState>,
|
||||
) -> Result<impl Responder, Error> {
|
||||
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<AuthState>,
|
||||
) -> Result<impl Responder, Error> {
|
||||
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<AuthRole>) -> bool {
|
||||
match self {
|
||||
Self::Public => true,
|
||||
Self::Read => role.is_some(),
|
||||
Self::Control => matches!(role, Some(AuthRole::Control)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication middleware
|
||||
pub struct AuthMiddleware;
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type InitError = ();
|
||||
type Transform = AuthMiddlewareService<S>;
|
||||
type Future = std::future::Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
std::future::ready(Ok(AuthMiddlewareService { service }))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthMiddlewareService<S> {
|
||||
service: S,
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
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::<web::Data<AuthState>>()
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user