Files
trx-rs/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs
T
sjg d68a84f7f9
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 3m18s
CI / reuse (push) Successful in 3s
[chore](trx-client): apply cargo fmt
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:57:33 +02:00

845 lines
26 KiB
Rust

// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! 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::{
cookie::Cookie,
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
get, post, web, Error, HttpRequest, HttpResponse, Responder,
};
use futures_util::future::LocalBoxFuture;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime};
use tracing::warn;
/// 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_or_else(|e| {
warn!("Session store lock poisoned (create), recovering");
e.into_inner()
});
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_or_else(|e| {
warn!("Session store lock poisoned (get), recovering");
e.into_inner()
});
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_or_else(|e| {
warn!("Session store lock poisoned (remove), recovering");
e.into_inner()
});
store.remove(session_id);
}
/// Remove all expired sessions
pub fn cleanup_expired(&self) {
let mut store = self.sessions.write().unwrap_or_else(|e| {
warn!("Session store lock poisoned (cleanup), recovering");
e.into_inner()
});
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, Default)]
pub enum SameSite {
Strict,
#[default]
Lax,
None,
}
impl SameSite {
pub fn as_str(&self) -> &'static str {
match self {
Self::Strict => "Strict",
Self::Lax => "Lax",
Self::None => "None",
}
}
}
/// 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
}
}
/// Simple per-IP rate limiter for login attempts.
///
/// Tracks failed attempts per IP and enforces a cooldown window after
/// exceeding the maximum number of attempts.
pub struct LoginRateLimiter {
/// Maps IP → (attempt_count, window_start).
attempts: Mutex<HashMap<String, (u32, Instant)>>,
/// Maximum allowed attempts within the window.
max_attempts: u32,
/// Duration of the rate-limit window.
window: Duration,
}
impl LoginRateLimiter {
pub fn new(max_attempts: u32, window: Duration) -> Self {
Self {
attempts: Mutex::new(HashMap::new()),
max_attempts,
window,
}
}
/// Check whether an IP is rate-limited. Returns `true` if the request
/// should be allowed, `false` if rate-limited.
pub fn check(&self, ip: &str) -> bool {
let mut map = self.attempts.lock().unwrap_or_else(|e| {
warn!("Rate limiter lock poisoned (check), recovering");
e.into_inner()
});
let now = Instant::now();
if let Some((count, window_start)) = map.get_mut(ip) {
if now.duration_since(*window_start) > self.window {
// Window expired, reset.
*count = 1;
*window_start = now;
true
} else if *count >= self.max_attempts {
false
} else {
*count += 1;
true
}
} else {
map.insert(ip.to_string(), (1, now));
true
}
}
/// Record a successful login — clears the rate-limit counter for the IP.
pub fn reset(&self, ip: &str) {
let mut map = self.attempts.lock().unwrap_or_else(|e| {
warn!("Rate limiter lock poisoned (reset), recovering");
e.into_inner()
});
map.remove(ip);
}
}
impl Default for LoginRateLimiter {
fn default() -> Self {
// 10 attempts per 60-second window.
Self::new(10, Duration::from_secs(60))
}
}
/// Application data for authentication
pub struct AuthState {
pub config: AuthConfig,
pub store: SessionStore,
pub rate_limiter: LoginRateLimiter,
}
impl AuthState {
pub fn new(config: AuthConfig) -> Self {
Self {
config,
store: SessionStore::new(),
rate_limiter: LoginRateLimiter::default(),
}
}
}
/// Constant-time string comparison to mitigate timing attacks
fn constant_time_eq(a: &str, b: &str) -> bool {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
if a_bytes.len() != b_bytes.len() {
return false;
}
let mut result = 0u8;
for (x, y) in a_bytes.iter().zip(b_bytes.iter()) {
result |= x ^ y;
}
result == 0
}
/// Login request body
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub passphrase: String,
}
/// 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")
.map(|cookie| 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());
}
// Per-IP rate limiting to mitigate brute-force attacks.
let peer_ip = req
.peer_addr()
.map(|a| a.ip().to_string())
.unwrap_or_default();
if !auth_state.rate_limiter.check(&peer_ip) {
return Ok(HttpResponse::TooManyRequests().json(serde_json::json!({
"error": "Too many login attempts, please try again later"
})));
}
// 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"
})));
}
};
// Successful login — clear rate limit counter.
auth_state.rate_limiter.reset(&peer_ip);
// 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 is disabled, grant full control access without requiring login
if !auth_state.config.enabled {
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true,
role: Some("control".to_string()),
}));
}
let session_id = extract_session_id(&req);
if let Some(session_record) = session_id.and_then(|sid| auth_state.store.get(&sid)) {
// User has valid session
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true,
role: Some(session_record.role.as_str().to_string()),
}));
}
// No session - check if rx access is unrestricted
if auth_state.config.rx_passphrase.is_none() {
// No rx passphrase required - grant rx role to unauthenticated users
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: false,
role: Some("rx".to_string()),
}));
}
// Auth required but no valid session
Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: false,
role: None,
}))
}
// ============================================================================
// 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 == "/map"
|| path == "/digital-modes"
|| path == "/settings"
|| path == "/about"
|| path.starts_with("/auth/")
{
return Self::Public;
}
// Static assets. The band plan is one of them: it is compiled into the
// binary and identical for every user, but ".json" is not an asset
// suffix, so it used to fall through to Control — leaving read-only
// users without a band plan, and everyone else without one whenever the
// page requested it before the session was established.
if path == "/bandplan.json" {
return Self::Public;
}
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 == "/rigs"
|| path == "/events"
|| path == "/decode"
|| path == "/decode/history"
|| path == "/spectrum"
|| path == "/meter"
|| path == "/audio"
|| path == "/bookmarks"
|| path.starts_with("/status?")
|| path.starts_with("/rigs?")
|| path.starts_with("/events?")
|| path.starts_with("/decode?")
|| path.starts_with("/decode/history?")
|| path.starts_with("/spectrum?")
|| path.starts_with("/meter?")
|| path.starts_with("/audio?")
|| path.starts_with("/bookmarks?")
|| path.starts_with("/bookmarks/")
|| path.starts_with("/scheduler/")
|| path.starts_with("/scheduler-control")
|| path.starts_with("/channels/")
{
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 rx_passphrase is not set, allow unauthenticated read access
let allow_unrestricted_read = auth_state.config.rx_passphrase.is_none();
let is_read_route = access == RouteAccess::Read;
if is_read_route && allow_unrestricted_read {
// No rx authentication required - allow read access without role
let fut = self.service.call(req);
return Box::pin(async move {
let res = fut.await?;
Ok(res)
});
}
if !access.allows(role) {
// Access denied
return Box::pin(async move {
if role.is_some() {
// Has session but insufficient permissions - 403 Forbidden
Err(actix_web::error::ErrorForbidden(
"Insufficient permissions".to_string(),
))
} else if allow_unrestricted_read {
// No session but rx access is unrestricted - 403 Forbidden
// (user has implicit rx role from unrestricted access)
Err(actix_web::error::ErrorForbidden(
"Insufficient permissions".to_string(),
))
} else {
// No session and no unrestricted access - 401 Unauthorized
Err(actix_web::error::ErrorUnauthorized(
"Authentication required".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 (used for TX access control).
pub fn is_tx_endpoint(path: &str) -> bool {
path.contains("ptt")
|| path.contains("set_ptt")
|| path.contains("toggle_ptt")
|| path.contains("set_tx")
|| path.contains("toggle_tx")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_route_access_public_paths() {
assert_eq!(RouteAccess::from_path("/"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/map"), RouteAccess::Public);
assert_eq!(
RouteAccess::from_path("/digital-modes"),
RouteAccess::Public
);
assert_eq!(RouteAccess::from_path("/settings"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/about"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/login"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/style.css"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public);
// Static reference data, served to every role: ".json" is not in the
// asset suffix list, so this one has to be named.
assert_eq!(
RouteAccess::from_path("/bandplan.json"),
RouteAccess::Public
);
}
#[test]
fn test_route_access_read_paths() {
assert_eq!(RouteAccess::from_path("/status"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/rigs"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/events"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/decode"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/spectrum"), RouteAccess::Read);
assert_eq!(RouteAccess::from_path("/meter"), 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_center_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"));
}
}