diff --git a/Cargo.lock b/Cargo.lock index 353b4f5c..5d12c475 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -281,6 +281,7 @@ dependencies = [ "sd-notify", "serde", "serde_json", + "subtle", "toml", "uuid", ] @@ -2623,6 +2624,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.104" diff --git a/aw-server/Cargo.toml b/aw-server/Cargo.toml index da6c908e..1962577b 100644 --- a/aw-server/Cargo.toml +++ b/aw-server/Cargo.toml @@ -28,6 +28,7 @@ gethostname = "0.4" uuid = { version = "1.3", features = ["serde", "v4"] } clap = { version = "4.1", features = ["derive", "cargo"] } log-panics = { version = "2", features = ["with-backtrace"]} +subtle = "2" rust-embed = { version = "8.0.0", features = ["interpolate-folder-path", "debug-embed"] } aw-datastore = { path = "../aw-datastore" } diff --git a/aw-server/src/config.rs b/aw-server/src/config.rs index 35ac435c..37d59bbd 100644 --- a/aw-server/src/config.rs +++ b/aw-server/src/config.rs @@ -19,6 +19,17 @@ pub fn is_testing() -> bool { unsafe { TESTING } } +/// Authentication configuration, serialised as `[auth]` in config.toml. +#[derive(Serialize, Deserialize, Default)] +pub struct AWAuthConfig { + /// Optional API key for Bearer-token authentication. + /// When set, all `/api/*` endpoints except `/api/0/info` require: + /// Authorization: Bearer + /// Leave unset (default) to disable authentication. + #[serde(default)] + pub api_key: Option, +} + #[derive(Serialize, Deserialize)] pub struct AWConfig { #[serde(default = "default_address")] @@ -36,6 +47,10 @@ pub struct AWConfig { #[serde(default = "default_cors")] pub cors_regex: Vec, + // Authentication settings — serialised as [auth] section. + #[serde(default)] + pub auth: AWAuthConfig, + // A mapping of watcher names to paths where the // custom visualizations are located. #[serde(default = "default_custom_static")] @@ -48,6 +63,7 @@ impl Default for AWConfig { address: default_address(), port: default_port(), testing: default_testing(), + auth: AWAuthConfig::default(), cors: default_cors(), cors_regex: default_cors(), custom_static: default_custom_static(), diff --git a/aw-server/src/endpoints/apikey.rs b/aw-server/src/endpoints/apikey.rs new file mode 100644 index 00000000..a651f211 --- /dev/null +++ b/aw-server/src/endpoints/apikey.rs @@ -0,0 +1,266 @@ +//! API key authentication via Bearer token. +//! +//! When `api_key` is set under `[auth]` in the config, all API endpoints except +//! `/api/0/info` require an `Authorization: Bearer ` header. Requests +//! missing or presenting an invalid key receive a 401 Unauthorized response. +//! +//! By default `api_key` is `None`, meaning authentication is disabled. +//! To enable, add to `config.toml`: +//! +//! ```toml +//! [auth] +//! api_key = "your-secret-key-here" +//! ``` +//! +//! Exempt paths (always public): +//! - `GET /api/0/info` — health/version endpoint used by clients and the webui +//! +//! CORS preflight requests (OPTIONS) are also passed through unconditionally so +//! the browser can obtain allowed headers before sending the actual request. + +use subtle::ConstantTimeEq; + +use rocket::fairing::Fairing; +use rocket::http::uri::Origin; +use rocket::http::{Method, Status}; +use rocket::route::Outcome; +use rocket::{Data, Request, Rocket, Route}; + +use crate::config::AWConfig; +use crate::endpoints::HttpErrorJson; + +static FAIRING_ROUTE_BASE: &str = "/apikey_fairing"; + +/// Paths that are always accessible without authentication. +const PUBLIC_PATHS: &[&str] = &["/api/0/info"]; + +pub struct ApiKeyCheck { + api_key: Option, +} + +impl ApiKeyCheck { + pub fn new(config: &AWConfig) -> ApiKeyCheck { + let api_key = match &config.auth.api_key { + Some(k) if k.is_empty() => { + warn!("api_key is set to an empty string — authentication is disabled. Set a non-empty key to enable auth."); + None + } + other => other.clone(), + }; + ApiKeyCheck { api_key } + } +} + +/// Route handler that returns 401 Unauthorized for failed auth checks. +#[derive(Clone)] +struct FairingErrorRoute {} + +#[rocket::async_trait] +impl rocket::route::Handler for FairingErrorRoute { + async fn handle<'r>( + &self, + request: &'r Request<'_>, + _: rocket::Data<'r>, + ) -> rocket::route::Outcome<'r> { + let err = HttpErrorJson::new( + Status::Unauthorized, + "Missing or invalid API key. Set 'Authorization: Bearer ' header.".to_string(), + ); + Outcome::from(request, err) + } +} + +fn fairing_route() -> Route { + Route::ranked(1, Method::Get, "/", FairingErrorRoute {}) +} + +fn redirect_unauthorized(request: &mut Request) { + let uri = FAIRING_ROUTE_BASE.to_string(); + let origin = Origin::parse_owned(uri).unwrap(); + request.set_method(Method::Get); + request.set_uri(origin); +} + +#[rocket::async_trait] +impl Fairing for ApiKeyCheck { + fn info(&self) -> rocket::fairing::Info { + rocket::fairing::Info { + name: "ApiKeyCheck", + kind: rocket::fairing::Kind::Ignite | rocket::fairing::Kind::Request, + } + } + + async fn on_ignite(&self, rocket: Rocket) -> rocket::fairing::Result { + match &self.api_key { + Some(_) => Ok(rocket.mount(FAIRING_ROUTE_BASE, vec![fairing_route()])), + None => { + debug!("API key authentication is disabled"); + Ok(rocket) + } + } + } + + async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) { + let api_key = match &self.api_key { + None => return, // auth disabled + Some(k) => k, + }; + + // Always allow OPTIONS (CORS preflight) + if request.method() == Method::Options { + return; + } + + // Only gate API endpoints — static web UI assets are not under /api/ + if !request.uri().path().as_str().starts_with("/api/") { + return; + } + + // Always allow public API paths (e.g. /api/0/info for health checks) + if PUBLIC_PATHS.contains(&request.uri().path().as_str()) { + return; + } + + // Validate Authorization: Bearer + // Use constant-time comparison to prevent timing attacks. + let auth_header = request.headers().get_one("Authorization"); + let valid = match auth_header { + Some(value) => { + if let Some(token) = value.strip_prefix("Bearer ") { + token.as_bytes().ct_eq(api_key.as_bytes()).into() + } else { + false + } + } + None => false, + }; + + if !valid { + debug!("API key check failed for {}", request.uri()); + redirect_unauthorized(request); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use rocket::http::{ContentType, Header, Status}; + use rocket::Rocket; + + use crate::config::AWConfig; + use crate::endpoints; + + fn setup_testserver(api_key: Option) -> Rocket { + let state = endpoints::ServerState { + datastore: Mutex::new(aw_datastore::Datastore::new_in_memory(false)), + asset_resolver: endpoints::AssetResolver::new(None), + device_id: "test_id".to_string(), + }; + let mut aw_config = AWConfig::default(); + aw_config.auth.api_key = api_key; + endpoints::build_rocket(state, aw_config) + } + + #[test] + fn test_no_api_key_configured() { + // When no api_key is set, all endpoints are accessible without auth. + let server = setup_testserver(None); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + let res = client + .get("/api/0/info") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + } + + #[test] + fn test_api_key_required() { + // With api_key set, requests without a key should be rejected. + let server = setup_testserver(Some("secret123".to_string())); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + // /api/0/info is always public + let res = client + .get("/api/0/info") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + + // Other endpoints require auth + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Unauthorized); + } + + #[test] + fn test_api_key_valid() { + let server = setup_testserver(Some("secret123".to_string())); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .header(Header::new("Authorization", "Bearer secret123")) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + } + + #[test] + fn test_api_key_invalid() { + let server = setup_testserver(Some("secret123".to_string())); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .header(Header::new("Authorization", "Bearer wrongkey")) + .dispatch(); + assert_eq!(res.status(), Status::Unauthorized); + } + + #[test] + fn test_api_key_wrong_scheme() { + // Must be Bearer, not Basic or bare key + let server = setup_testserver(Some("secret123".to_string())); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .header(Header::new("Authorization", "Basic secret123")) + .dispatch(); + assert_eq!(res.status(), Status::Unauthorized); + } + + #[test] + fn test_empty_api_key_disables_auth() { + // An empty string key should be treated as disabled (no auth required). + let server = setup_testserver(Some("".to_string())); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + } +} diff --git a/aw-server/src/endpoints/mod.rs b/aw-server/src/endpoints/mod.rs index f6c9271e..f0621196 100644 --- a/aw-server/src/endpoints/mod.rs +++ b/aw-server/src/endpoints/mod.rs @@ -46,6 +46,7 @@ pub struct ServerState { #[macro_use] mod util; +mod apikey; mod bucket; mod cors; mod export; @@ -134,11 +135,13 @@ pub fn build_rocket(server_state: ServerState, config: AWConfig) -> rocket::Rock ); let cors = cors::cors(&config); let hostcheck = hostcheck::HostCheck::new(&config); + let apikey = apikey::ApiKeyCheck::new(&config); let custom_static = config.custom_static.clone(); let mut rocket = rocket::custom(config.to_rocket_config()) .attach(cors.clone()) .attach(hostcheck) + .attach(apikey) .manage(cors) .manage(server_state) .manage(config)