From e1739d9818f74f939b8eacd56461837831109e2c Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Sat, 29 Jun 2024 14:43:56 +0200 Subject: [PATCH] Add authentication layer --- central_backend/Cargo.lock | 214 +++++++++++++++++- central_backend/Cargo.toml | 7 +- central_backend/src/app_config.rs | 58 +++++ central_backend/src/constants.rs | 13 ++ central_backend/src/main.rs | 6 +- central_backend/src/server/auth_middleware.rs | 97 ++++++++ central_backend/src/server/custom_error.rs | 13 ++ central_backend/src/server/mod.rs | 69 +----- central_backend/src/server/servers.rs | 134 +++++++++++ .../src/server/unsecure_server/mod.rs | 2 + .../unsecure_pki_controller.rs} | 0 .../unsecure_server_controller.rs} | 6 - .../src/server/web_api/auth_controller.rs | 52 +++++ .../server/{ => web_api}/energy_controller.rs | 0 central_backend/src/server/web_api/mod.rs | 3 + .../src/server/web_api/server_controller.rs | 25 ++ central_frontend/.env | 1 + central_frontend/.env.production | 1 + central_frontend/src/App.tsx | 7 +- central_frontend/src/api/ApiClient.ts | 177 +++++++++++++++ central_frontend/src/api/AuthApi.ts | 70 ++++++ central_frontend/src/api/ServerApi.ts | 29 +++ .../ConfirmDialogProvider.tsx | 4 +- central_frontend/src/main.tsx | 9 +- central_frontend/src/routes/LoginRoute.tsx | 39 +++- central_frontend/src/widgets/AsyncWidget.tsx | 92 ++++++++ 26 files changed, 1038 insertions(+), 90 deletions(-) create mode 100644 central_backend/src/server/auth_middleware.rs create mode 100644 central_backend/src/server/servers.rs create mode 100644 central_backend/src/server/unsecure_server/mod.rs rename central_backend/src/server/{pki_controller.rs => unsecure_server/unsecure_pki_controller.rs} (100%) rename central_backend/src/server/{server_controller.rs => unsecure_server/unsecure_server_controller.rs} (54%) create mode 100644 central_backend/src/server/web_api/auth_controller.rs rename central_backend/src/server/{ => web_api}/energy_controller.rs (100%) create mode 100644 central_backend/src/server/web_api/mod.rs create mode 100644 central_backend/src/server/web_api/server_controller.rs create mode 100644 central_frontend/.env create mode 100644 central_frontend/.env.production create mode 100644 central_frontend/src/api/ApiClient.ts create mode 100644 central_frontend/src/api/AuthApi.ts create mode 100644 central_frontend/src/api/ServerApi.ts create mode 100644 central_frontend/src/widgets/AsyncWidget.tsx diff --git a/central_backend/Cargo.lock b/central_backend/Cargo.lock index 88a741e..2470f55 100644 --- a/central_backend/Cargo.lock +++ b/central_backend/Cargo.lock @@ -44,6 +44,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "actix-cors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e772b3bcafe335042b5db010ab7c09013dad6eac4915c91d8d50902769f331" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + [[package]] name = "actix-http" version = "3.8.0" @@ -56,7 +71,7 @@ dependencies = [ "actix-tls", "actix-utils", "ahash", - "base64", + "base64 0.22.1", "bitflags 2.6.0", "brotli", "bytes", @@ -84,6 +99,22 @@ dependencies = [ "zstd", ] +[[package]] +name = "actix-identity" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c99b7a5614b72a78f04aa2021e5370fc1aef2475fffeffc0c1266b99007062" +dependencies = [ + "actix-service", + "actix-session", + "actix-utils", + "actix-web", + "derive_more", + "futures-core", + "serde", + "tracing", +] + [[package]] name = "actix-macros" version = "0.2.4" @@ -94,6 +125,17 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-remote-ip" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7629b357d4705cf3f1e31f989f48ecd56027112f7d52dcf06dd96ee197065f8e" +dependencies = [ + "actix-web", + "futures-util", + "log", +] + [[package]] name = "actix-router" version = "0.5.3" @@ -147,6 +189,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "actix-session" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b671404ec72194d8af58c2bdaf51e3c477a0595056bd5010148405870dda8df2" +dependencies = [ + "actix-service", + "actix-utils", + "actix-web", + "anyhow", + "derive_more", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "actix-tls" version = "3.4.0" @@ -256,6 +314,41 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.11" @@ -395,6 +488,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" + [[package]] name = "base64" version = "0.22.1" @@ -480,6 +579,10 @@ name = "central_backend" version = "0.1.0" dependencies = [ "actix", + "actix-cors", + "actix-identity", + "actix-remote-ip", + "actix-session", "actix-web", "anyhow", "asn1", @@ -487,6 +590,7 @@ dependencies = [ "env_logger", "foreign-types-shared", "futures", + "futures-util", "lazy_static", "libc", "log", @@ -505,6 +609,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.7" @@ -563,7 +677,14 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ + "aes-gcm", + "base64 0.20.0", + "hkdf", + "hmac", "percent-encoding", + "rand", + "sha2", + "subtle", "time", "version_check", ] @@ -624,9 +745,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "deranged" version = "0.3.11" @@ -657,6 +788,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -863,6 +995,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.29.0" @@ -919,6 +1061,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "0.2.12" @@ -1081,6 +1241,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -1245,6 +1414,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.64" @@ -1362,6 +1537,18 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1472,7 +1659,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -1571,7 +1758,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" dependencies = [ - "base64", + "base64 0.22.1", "rustls-pki-types", ] @@ -1696,6 +1883,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -2007,6 +2205,16 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/central_backend/Cargo.toml b/central_backend/Cargo.toml index 1d17568..0503bb9 100644 --- a/central_backend/Cargo.toml +++ b/central_backend/Cargo.toml @@ -21,4 +21,9 @@ serde = { version = "1.0.203", features = ["derive"] } reqwest = "0.12.5" serde_json = "1.0.118" rand = "0.8.5" -actix = "0.13.5" \ No newline at end of file +actix = "0.13.5" +actix-identity = "0.7.1" +actix-session = { version = "0.9.0", features = ["cookie-session"] } +actix-cors = "0.7.0" +actix-remote-ip = "0.1.0" +futures-util = "0.3.30" \ No newline at end of file diff --git a/central_backend/src/app_config.rs b/central_backend/src/app_config.rs index 809daff..802a9ab 100644 --- a/central_backend/src/app_config.rs +++ b/central_backend/src/app_config.rs @@ -23,6 +23,32 @@ pub enum ConsumptionBackend { #[derive(Parser, Debug)] #[command(version, about, long_about = None)] pub struct AppConfig { + /// Proxy IP, might end with a star "*" + #[clap(short, long, env)] + pub proxy_ip: Option, + + /// Secret key, used to sign some resources. Must be randomly generated + #[clap(short = 'S', long, env, default_value = "")] + secret: String, + + /// Specify whether the cookie should be transmitted only over secure connections + /// + /// This should be always true when running in production mode + #[clap(long, env)] + pub cookie_secure: bool, + + /// Unsecure : for development, bypass authentication + #[clap(long, env)] + pub unsecure_disable_login: bool, + + /// Admin username + #[clap(long, env, default_value = "admin")] + pub admin_username: String, + + /// Admin password + #[clap(long, env, default_value = "admin")] + pub admin_password: String, + /// The port the server will listen to (using HTTPS) #[arg(short, long, env, default_value = "0.0.0.0:8443")] pub listen_address: String, @@ -56,6 +82,21 @@ impl AppConfig { &ARGS } + /// Get app secret + pub fn secret(&self) -> &str { + let mut secret = self.secret.as_str(); + + if cfg!(debug_assertions) && secret.is_empty() { + secret = "DEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEY"; + } + + if secret.is_empty() { + panic!("SECRET is undefined or too short (min 64 chars)!") + } + + secret + } + /// URL for unsecure connections pub fn unsecure_origin(&self) -> String { format!( @@ -74,6 +115,23 @@ impl AppConfig { ) } + /// Get auth cookie domain + pub fn cookie_domain(&self) -> Option { + if cfg!(debug_assertions) { + let domain = self.secure_origin().split_once("://")?.1.to_string(); + Some( + domain + .split_once(':') + .map(|s| s.0) + .unwrap_or(&domain) + .to_string(), + ) + } else { + // In release mode, the web app is hosted on the same origin as the API + None + } + } + /// Get storage path pub fn storage_path(&self) -> PathBuf { Path::new(&self.storage).to_path_buf() diff --git a/central_backend/src/constants.rs b/central_backend/src/constants.rs index a50e705..fb388bf 100644 --- a/central_backend/src/constants.rs +++ b/central_backend/src/constants.rs @@ -1,7 +1,20 @@ use std::time::Duration; +/// Name of the cookie that contains session information +pub const SESSION_COOKIE_NAME: &str = "X-session-cookie"; + /// Energy refresh operations interval pub const ENERGY_REFRESH_INTERVAL: Duration = Duration::from_secs(30); /// Fallback value to use if production cannot be fetched pub const FALLBACK_PRODUCTION_VALUE: i32 = 5000; + +/// Maximum session duration after inactivity, in seconds +pub const MAX_INACTIVITY_DURATION: u64 = 3600; + +/// Maximum session duration (1 day) +pub const MAX_SESSION_DURATION: u64 = 3600 * 24; + +/// List of routes that do not require authentication +pub const ROUTES_WITHOUT_AUTH: [&str; 2] = + ["/web_api/server/config", "/web_api/auth/password_auth"]; diff --git a/central_backend/src/main.rs b/central_backend/src/main.rs index aa10392..ad28125 100644 --- a/central_backend/src/main.rs +++ b/central_backend/src/main.rs @@ -2,7 +2,7 @@ use actix::Actor; use central_backend::app_config::AppConfig; use central_backend::crypto::pki; use central_backend::energy::energy_actor::EnergyActor; -use central_backend::server::{secure_server, unsecure_server}; +use central_backend::server::servers; use central_backend::utils::files_utils::create_directory_if_missing; use futures::future; @@ -30,8 +30,8 @@ async fn main() -> std::io::Result<()> { .expect("Failed to initialize energy actor!") .start(); - let s1 = secure_server(actor); - let s2 = unsecure_server(); + let s1 = servers::secure_server(actor); + let s2 = servers::unsecure_server(); future::try_join(s1, s2) .await .expect("Failed to start servers!"); diff --git a/central_backend/src/server/auth_middleware.rs b/central_backend/src/server/auth_middleware.rs new file mode 100644 index 0000000..a310303 --- /dev/null +++ b/central_backend/src/server/auth_middleware.rs @@ -0,0 +1,97 @@ +use actix_identity::Identity; +use std::future::{ready, Ready}; +use std::rc::Rc; + +use crate::app_config::AppConfig; +use crate::constants; +use actix_web::body::EitherBody; +use actix_web::dev::Payload; +use actix_web::{ + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + Error, FromRequest, HttpResponse, +}; +use futures_util::future::LocalBoxFuture; + +// There are two steps in middleware processing. +// 1. Middleware initialization, middleware factory gets called with +// next service in chain as parameter. +// 2. Middleware's call method gets called with normal request. +#[derive(Default)] +pub struct AuthChecker; + +// Middleware factory is `Transform` trait +// `S` - type of the next service +// `B` - type of response's body +impl Transform for AuthChecker +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Transform = AuthMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(AuthMiddleware { + service: Rc::new(service), + })) + } +} + +pub struct AuthMiddleware { + service: Rc, +} + +impl Service for AuthMiddleware +where + S: Service, Error = Error> + 'static, + 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 service = Rc::clone(&self.service); + + Box::pin(async move { + // Check if no authentication is required + if constants::ROUTES_WITHOUT_AUTH.contains(&req.path()) + || !req.path().starts_with("/web_api/") + { + log::trace!("No authentication is required") + } + // Dev only, check for auto login + else if AppConfig::get().unsecure_disable_login { + log::trace!("Authentication is disabled") + } + // Check cookie authentication + else { + let identity: Option = + Identity::from_request(req.request(), &mut Payload::None) + .into_inner() + .ok(); + + if identity.is_none() { + log::error!( + "Missing identity information in request, user is not authenticated!" + ); + return Ok(req + .into_response(HttpResponse::PreconditionFailed().finish()) + .map_into_right_body()); + }; + } + + service + .call(req) + .await + .map(ServiceResponse::map_into_left_body) + }) + } +} diff --git a/central_backend/src/server/custom_error.rs b/central_backend/src/server/custom_error.rs index d94ac14..a7569cb 100644 --- a/central_backend/src/server/custom_error.rs +++ b/central_backend/src/server/custom_error.rs @@ -91,9 +91,22 @@ impl From for HttpErr { } } +impl From for HttpErr { + fn from(value: actix_identity::error::GetIdentityError) -> Self { + HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into()) + } +} + +impl From for HttpErr { + fn from(value: actix_identity::error::LoginError) -> Self { + HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into()) + } +} + impl From for HttpErr { fn from(value: HttpResponse) -> Self { HttpErr::HTTPResponse(value) } } + pub type HttpResult = Result; diff --git a/central_backend/src/server/mod.rs b/central_backend/src/server/mod.rs index 022699d..81790af 100644 --- a/central_backend/src/server/mod.rs +++ b/central_backend/src/server/mod.rs @@ -1,70 +1,11 @@ -use actix_web::middleware::Logger; -use actix_web::{web, App, HttpServer}; -use openssl::ssl::{SslAcceptor, SslMethod}; +use actix_web::web; -use crate::app_config::AppConfig; -use crate::crypto::pki; use crate::energy::energy_actor::EnergyActorAddr; +pub mod auth_middleware; pub mod custom_error; -pub mod energy_controller; -pub mod pki_controller; -pub mod server_controller; +pub mod servers; +pub mod unsecure_server; +pub mod web_api; pub type WebEnergyActor = web::Data; - -/// Start unsecure (HTTP) server -pub async fn unsecure_server() -> anyhow::Result<()> { - log::info!( - "Unsecure server starting to listen on {} for {}", - AppConfig::get().unsecure_listen_address, - AppConfig::get().unsecure_origin() - ); - HttpServer::new(|| { - App::new() - .wrap(Logger::default()) - .route("/", web::get().to(server_controller::unsecure_home)) - .route("/pki/{file}", web::get().to(pki_controller::serve_pki_file)) - }) - .bind(&AppConfig::get().unsecure_listen_address)? - .run() - .await?; - - Ok(()) -} - -/// Start secure (HTTPS) server -pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> { - let web_ca = pki::CertData::load_web_ca()?; - let server_cert = pki::CertData::load_server()?; - - let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap(); - builder.set_private_key(&server_cert.key)?; - builder.set_certificate(&server_cert.cert)?; - builder.add_extra_chain_cert(web_ca.cert)?; - - log::info!( - "Secure server starting to listen on {} for {}", - AppConfig::get().listen_address, - AppConfig::get().secure_origin() - ); - HttpServer::new(move || { - App::new() - .app_data(web::Data::new(energy_actor.clone())) - .wrap(Logger::default()) - .route("/", web::get().to(server_controller::secure_home)) - .route( - "/api/energy/curr_consumption", - web::get().to(energy_controller::curr_consumption), - ) - .route( - "/api/energy/cached_consumption", - web::get().to(energy_controller::cached_consumption), - ) - }) - .bind_openssl(&AppConfig::get().listen_address, builder)? - .run() - .await?; - - Ok(()) -} diff --git a/central_backend/src/server/servers.rs b/central_backend/src/server/servers.rs new file mode 100644 index 0000000..09b4748 --- /dev/null +++ b/central_backend/src/server/servers.rs @@ -0,0 +1,134 @@ +use crate::app_config::AppConfig; +use crate::constants; +use crate::crypto::pki; +use crate::energy::energy_actor::EnergyActorAddr; +use crate::server::auth_middleware::AuthChecker; +use crate::server::unsecure_server::*; +use crate::server::web_api::*; +use actix_cors::Cors; +use actix_identity::config::LogoutBehaviour; +use actix_identity::IdentityMiddleware; +use actix_remote_ip::RemoteIPConfig; +use actix_session::storage::CookieSessionStore; +use actix_session::SessionMiddleware; +use actix_web::cookie::{Key, SameSite}; +use actix_web::middleware::Logger; +use actix_web::{web, App, HttpServer}; +use openssl::ssl::{SslAcceptor, SslMethod}; +use std::time::Duration; + +/// Start unsecure (HTTP) server +pub async fn unsecure_server() -> anyhow::Result<()> { + log::info!( + "Unsecure server starting to listen on {} for {}", + AppConfig::get().unsecure_listen_address, + AppConfig::get().unsecure_origin() + ); + HttpServer::new(|| { + App::new() + .wrap(Logger::default()) + .route( + "/", + web::get().to(unsecure_server_controller::unsecure_home), + ) + .route( + "/pki/{file}", + web::get().to(unsecure_pki_controller::serve_pki_file), + ) + }) + .bind(&AppConfig::get().unsecure_listen_address)? + .run() + .await?; + + Ok(()) +} + +/// Start secure (HTTPS) server +pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()> { + let web_ca = pki::CertData::load_web_ca()?; + let server_cert = pki::CertData::load_server()?; + + let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap(); + builder.set_private_key(&server_cert.key)?; + builder.set_certificate(&server_cert.cert)?; + builder.add_extra_chain_cert(web_ca.cert)?; + + log::info!( + "Secure server starting to listen on {} for {}", + AppConfig::get().listen_address, + AppConfig::get().secure_origin() + ); + HttpServer::new(move || { + let session_mw = SessionMiddleware::builder( + CookieSessionStore::default(), + Key::from(AppConfig::get().secret().as_bytes()), + ) + .cookie_name(constants::SESSION_COOKIE_NAME.to_string()) + .cookie_secure(AppConfig::get().cookie_secure) + .cookie_same_site(SameSite::Strict) + .cookie_domain(AppConfig::get().cookie_domain()) + .cookie_http_only(true) + .build(); + + let identity_middleware = IdentityMiddleware::builder() + .logout_behaviour(LogoutBehaviour::PurgeSession) + .visit_deadline(Some(Duration::from_secs( + constants::MAX_INACTIVITY_DURATION, + ))) + .login_deadline(Some(Duration::from_secs(constants::MAX_SESSION_DURATION))) + .build(); + + let mut cors = Cors::default() + .allowed_origin(&AppConfig::get().secure_origin()) + .allowed_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) + .allowed_header("X-Auth-Token") + .allow_any_header() + .supports_credentials() + .max_age(3600); + + if cfg!(debug_assertions) { + cors = cors.allow_any_origin(); + } + + App::new() + .app_data(web::Data::new(energy_actor.clone())) + .wrap(Logger::default()) + .wrap(AuthChecker) + .wrap(identity_middleware) + .wrap(session_mw) + .wrap(cors) + .app_data(web::Data::new(RemoteIPConfig { + proxy: AppConfig::get().proxy_ip.clone(), + })) + .route("/", web::get().to(server_controller::secure_home)) + .route( + "/web_api/server/config", + web::get().to(server_controller::config), + ) + .route( + "/web_api/auth/password_auth", + web::post().to(auth_controller::password_auth), + ) + .route( + "/web_api/auth/info", + web::get().to(auth_controller::auth_info), + ) + .route( + "/web_api/auth/sign_out", + web::get().to(auth_controller::sign_out), + ) + .route( + "/web_api/energy/curr_consumption", + web::get().to(energy_controller::curr_consumption), + ) + .route( + "/web_api/energy/cached_consumption", + web::get().to(energy_controller::cached_consumption), + ) + }) + .bind_openssl(&AppConfig::get().listen_address, builder)? + .run() + .await?; + + Ok(()) +} diff --git a/central_backend/src/server/unsecure_server/mod.rs b/central_backend/src/server/unsecure_server/mod.rs new file mode 100644 index 0000000..95a8ad0 --- /dev/null +++ b/central_backend/src/server/unsecure_server/mod.rs @@ -0,0 +1,2 @@ +pub mod unsecure_pki_controller; +pub mod unsecure_server_controller; diff --git a/central_backend/src/server/pki_controller.rs b/central_backend/src/server/unsecure_server/unsecure_pki_controller.rs similarity index 100% rename from central_backend/src/server/pki_controller.rs rename to central_backend/src/server/unsecure_server/unsecure_pki_controller.rs diff --git a/central_backend/src/server/server_controller.rs b/central_backend/src/server/unsecure_server/unsecure_server_controller.rs similarity index 54% rename from central_backend/src/server/server_controller.rs rename to central_backend/src/server/unsecure_server/unsecure_server_controller.rs index 477d775..db2b282 100644 --- a/central_backend/src/server/server_controller.rs +++ b/central_backend/src/server/unsecure_server/unsecure_server_controller.rs @@ -5,9 +5,3 @@ pub async fn unsecure_home() -> HttpResponse { .content_type("text/plain") .body("SolarEnergy unsecure central backend") } - -pub async fn secure_home() -> HttpResponse { - HttpResponse::Ok() - .content_type("text/plain") - .body("SolarEnergy secure central backend") -} diff --git a/central_backend/src/server/web_api/auth_controller.rs b/central_backend/src/server/web_api/auth_controller.rs new file mode 100644 index 0000000..b593968 --- /dev/null +++ b/central_backend/src/server/web_api/auth_controller.rs @@ -0,0 +1,52 @@ +use crate::app_config::AppConfig; +use crate::server::custom_error::HttpResult; +use actix_identity::Identity; +use actix_remote_ip::RemoteIP; +use actix_web::{web, HttpMessage, HttpRequest, HttpResponse}; + +#[derive(serde::Deserialize)] +pub struct AuthRequest { + user: String, + password: String, +} + +/// Perform password authentication +pub async fn password_auth( + r: web::Json, + request: HttpRequest, + remote_ip: RemoteIP, +) -> HttpResult { + if r.user != AppConfig::get().admin_username || r.password != AppConfig::get().admin_password { + log::error!("Failed login attempt from {}!", remote_ip.0.to_string()); + return Ok(HttpResponse::Unauthorized().json("Invalid credentials!")); + } + + log::info!("Successful login attempt from {}!", remote_ip.0.to_string()); + Identity::login(&request.extensions(), r.user.to_string())?; + Ok(HttpResponse::Ok().finish()) +} + +#[derive(serde::Serialize)] +struct AuthInfo { + id: String, +} + +/// Get current user information +pub async fn auth_info(id: Option) -> HttpResult { + if AppConfig::get().unsecure_disable_login { + return Ok(HttpResponse::Ok().json(AuthInfo { + id: "auto login".to_string(), + })); + } + + Ok(HttpResponse::Ok().json(AuthInfo { + id: id.unwrap().id()?, + })) +} + +/// Sign out user +pub async fn sign_out(id: Identity) -> HttpResult { + id.logout(); + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/central_backend/src/server/energy_controller.rs b/central_backend/src/server/web_api/energy_controller.rs similarity index 100% rename from central_backend/src/server/energy_controller.rs rename to central_backend/src/server/web_api/energy_controller.rs diff --git a/central_backend/src/server/web_api/mod.rs b/central_backend/src/server/web_api/mod.rs new file mode 100644 index 0000000..57ed8ab --- /dev/null +++ b/central_backend/src/server/web_api/mod.rs @@ -0,0 +1,3 @@ +pub mod auth_controller; +pub mod energy_controller; +pub mod server_controller; diff --git a/central_backend/src/server/web_api/server_controller.rs b/central_backend/src/server/web_api/server_controller.rs new file mode 100644 index 0000000..bda9c78 --- /dev/null +++ b/central_backend/src/server/web_api/server_controller.rs @@ -0,0 +1,25 @@ +use crate::app_config::AppConfig; +use actix_web::HttpResponse; + +pub async fn secure_home() -> HttpResponse { + HttpResponse::Ok() + .content_type("text/plain") + .body("SolarEnergy secure central backend") +} + +#[derive(serde::Serialize)] +struct ServerConfig { + auth_disabled: bool, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + auth_disabled: AppConfig::get().unsecure_disable_login, + } + } +} + +pub async fn config() -> HttpResponse { + HttpResponse::Ok().json(ServerConfig::default()) +} diff --git a/central_frontend/.env b/central_frontend/.env new file mode 100644 index 0000000..e149fde --- /dev/null +++ b/central_frontend/.env @@ -0,0 +1 @@ +VITE_APP_BACKEND=https://localhost:8443/web_api diff --git a/central_frontend/.env.production b/central_frontend/.env.production new file mode 100644 index 0000000..feeb0eb --- /dev/null +++ b/central_frontend/.env.production @@ -0,0 +1 @@ +VITE_APP_BACKEND=/web_api diff --git a/central_frontend/src/App.tsx b/central_frontend/src/App.tsx index 59eb8b2..5950a66 100644 --- a/central_frontend/src/App.tsx +++ b/central_frontend/src/App.tsx @@ -1,5 +1,10 @@ +import { AuthApi } from "./api/AuthApi"; +import { ServerApi } from "./api/ServerApi"; import { LoginRoute } from "./routes/LoginRoute"; export function App() { - return ; + if (!AuthApi.SignedIn && !ServerApi.Config.auth_disabled) + return ; + + return <>logged in todo; } diff --git a/central_frontend/src/api/ApiClient.ts b/central_frontend/src/api/ApiClient.ts new file mode 100644 index 0000000..213ddbc --- /dev/null +++ b/central_frontend/src/api/ApiClient.ts @@ -0,0 +1,177 @@ +import { AuthApi } from "./AuthApi"; + +interface RequestParams { + uri: string; + method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT"; + allowFail?: boolean; + jsonData?: any; + formData?: FormData; + upProgress?: (progress: number) => void; + downProgress?: (e: { progress: number; total: number }) => void; +} + +interface APIResponse { + data: any; + status: number; +} + +export class ApiError extends Error { + constructor(message: string, public code: number, public data: any) { + super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`); + } +} + +export class APIClient { + /** + * Get backend URL + */ + static backendURL(): string { + const URL = import.meta.env.VITE_APP_BACKEND ?? ""; + if (URL.length === 0) throw new Error("Backend URL undefined!"); + return URL; + } + + /** + * Check out whether the backend is accessed through + * HTTPS or not + */ + static IsBackendSecure(): boolean { + return this.backendURL().startsWith("https"); + } + + /** + * Perform a request on the backend + */ + static async exec(args: RequestParams): Promise { + let body: string | undefined | FormData = undefined; + let headers: any = {}; + + // JSON request + if (args.jsonData) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(args.jsonData); + } + + // Form data request + else if (args.formData) { + body = args.formData; + } + + const url = this.backendURL() + args.uri; + + let data; + let status: number; + + // Make the request with XMLHttpRequest + if (args.upProgress) { + const res: XMLHttpRequest = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener("progress", (e) => + args.upProgress!(e.loaded / e.total) + ); + xhr.addEventListener("load", () => resolve(xhr)); + xhr.addEventListener("error", () => + reject(new Error("File upload failed")) + ); + xhr.addEventListener("abort", () => + reject(new Error("File upload aborted")) + ); + xhr.addEventListener("timeout", () => + reject(new Error("File upload timeout")) + ); + xhr.open(args.method, url, true); + xhr.withCredentials = true; + for (const key in headers) { + if (headers.hasOwnProperty(key)) + xhr.setRequestHeader(key, headers[key]); + } + xhr.send(body); + }); + + status = res.status; + if (res.responseType === "json") data = JSON.parse(res.responseText); + else data = res.response; + } + + // Make the request with fetch + else { + const res = await fetch(url, { + method: args.method, + body: body, + headers: headers, + credentials: "include", + }); + + // Process response + // JSON response + if (res.headers.get("content-type") === "application/json") + data = await res.json(); + // Text / XML response + else if ( + ["application/xml", "text/plain"].includes( + res.headers.get("content-type") ?? "" + ) + ) + data = await res.text(); + // Binary file, tracking download progress + else if (res.body !== null && args.downProgress) { + // Track download progress + const contentEncoding = res.headers.get("content-encoding"); + const contentLength = contentEncoding + ? null + : res.headers.get("content-length"); + + const total = parseInt(contentLength ?? "0", 10); + let loaded = 0; + + const resInt = new Response( + new ReadableStream({ + start(controller) { + const reader = res.body!.getReader(); + + const read = async () => { + try { + const ret = await reader.read(); + if (ret.done) { + controller.close(); + return; + } + loaded += ret.value.byteLength; + args.downProgress!({ progress: loaded, total }); + controller.enqueue(ret.value); + read(); + } catch (e) { + console.error(e); + controller.error(e); + } + }; + + read(); + }, + }) + ); + + data = await resInt.blob(); + } + + // Do not track progress (binary file) + else data = await res.blob(); + + status = res.status; + } + + // Handle expired tokens + if (status === 412) { + AuthApi.UnsetAuthenticated(); + window.location.href = import.meta.env.VITE_APP_BASENAME; + } + + if (!args.allowFail && (status < 200 || status > 299)) + throw new ApiError("Request failed!", status, data); + + return { + data: data, + status: status, + }; + } +} diff --git a/central_frontend/src/api/AuthApi.ts b/central_frontend/src/api/AuthApi.ts new file mode 100644 index 0000000..653a18b --- /dev/null +++ b/central_frontend/src/api/AuthApi.ts @@ -0,0 +1,70 @@ +import { APIClient } from "./ApiClient"; + +export interface AuthInfo { + name: string; +} + +const TokenStateKey = "auth-state"; + +export class AuthApi { + /** + * Check out whether user is signed in or not + */ + static get SignedIn(): boolean { + return localStorage.getItem(TokenStateKey) !== null; + } + + /** + * Mark user as authenticated + */ + static SetAuthenticated() { + localStorage.setItem(TokenStateKey, ""); + } + + /** + * Un-mark user as authenticated + */ + static UnsetAuthenticated() { + localStorage.removeItem(TokenStateKey); + } + + /** + * Authenticate using user and password + */ + static async AuthWithPassword(user: string, password: string): Promise { + await APIClient.exec({ + uri: "/auth/password_auth", + method: "POST", + jsonData: { + user, + password, + }, + }); + + this.SetAuthenticated(); + } + + /** + * Get auth information + */ + static async GetAuthInfo(): Promise { + return ( + await APIClient.exec({ + uri: "/auth/info", + method: "GET", + }) + ).data; + } + + /** + * Sign out + */ + static async SignOut(): Promise { + await APIClient.exec({ + uri: "/auth/sign_out", + method: "GET", + }); + + this.UnsetAuthenticated(); + } +} diff --git a/central_frontend/src/api/ServerApi.ts b/central_frontend/src/api/ServerApi.ts new file mode 100644 index 0000000..5b78c53 --- /dev/null +++ b/central_frontend/src/api/ServerApi.ts @@ -0,0 +1,29 @@ +import { APIClient } from "./ApiClient"; + +export interface ServerConfig { + auth_disabled: boolean; +} + +let config: ServerConfig | null = null; + +export class ServerApi { + /** + * Get server configuration + */ + static async LoadConfig(): Promise { + config = ( + await APIClient.exec({ + uri: "/server/config", + method: "GET", + }) + ).data; + } + + /** + * Get cached configuration + */ + static get Config(): ServerConfig { + if (config === null) throw new Error("Missing configuration!"); + return config; + } +} diff --git a/central_frontend/src/hooks/context_providers/ConfirmDialogProvider.tsx b/central_frontend/src/hooks/context_providers/ConfirmDialogProvider.tsx index 792b569..8b52c9a 100644 --- a/central_frontend/src/hooks/context_providers/ConfirmDialogProvider.tsx +++ b/central_frontend/src/hooks/context_providers/ConfirmDialogProvider.tsx @@ -72,10 +72,10 @@ export function ConfirmDialogProvider( diff --git a/central_frontend/src/main.tsx b/central_frontend/src/main.tsx index 8848b9e..03a21cb 100644 --- a/central_frontend/src/main.tsx +++ b/central_frontend/src/main.tsx @@ -11,6 +11,8 @@ import { DarkThemeProvider } from "./hooks/context_providers/DarkThemeProvider"; import { LoadingMessageProvider } from "./hooks/context_providers/LoadingMessageProvider"; import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider"; import "./index.css"; +import { ServerApi } from "./api/ServerApi"; +import { AsyncWidget } from "./widgets/AsyncWidget"; ReactDOM.createRoot(document.getElementById("root")!).render( @@ -19,7 +21,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - + await ServerApi.LoadConfig()} + errMsg="Failed to connect to backend to retrieve static config!" + build={() => } + /> diff --git a/central_frontend/src/routes/LoginRoute.tsx b/central_frontend/src/routes/LoginRoute.tsx index c153909..52fd48f 100644 --- a/central_frontend/src/routes/LoginRoute.tsx +++ b/central_frontend/src/routes/LoginRoute.tsx @@ -1,16 +1,18 @@ -import * as React from "react"; +import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; +import { Alert } from "@mui/material"; import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import CssBaseline from "@mui/material/CssBaseline"; -import TextField from "@mui/material/TextField"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Checkbox from "@mui/material/Checkbox"; +import Grid from "@mui/material/Grid"; import Link from "@mui/material/Link"; import Paper from "@mui/material/Paper"; -import Box from "@mui/material/Box"; -import Grid from "@mui/material/Grid"; -import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; +import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; +import * as React from "react"; +import { useAlert } from "../hooks/context_providers/AlertDialogProvider"; +import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider"; +import { AuthApi } from "../api/AuthApi"; function Copyright(props: any) { return ( @@ -31,12 +33,28 @@ function Copyright(props: any) { } export function LoginRoute() { + const loadingMessage = useLoadingMessage(); + const [user, setUser] = React.useState(""); const [password, setPassword] = React.useState(""); - const handleSubmit = (event: React.FormEvent) => { + const [error, setError] = React.useState(); + + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); - // TODO + try { + loadingMessage.show("Signing in..."); + setError(undefined); + + await AuthApi.AuthWithPassword(user, password); + + location.href = "/"; + } catch (e) { + console.error("Failed to perform login!", e); + setError(`Failed to authenticate! ${e}`); + } finally { + loadingMessage.hide(); + } }; return ( @@ -73,6 +91,9 @@ export function LoginRoute() { SolarEnergy + + {error && {error}} + Promise; + errMsg: string; + build: () => React.ReactElement; + ready?: boolean; + errAdditionalElement?: () => React.ReactElement; +}): React.ReactElement { + const [state, setState] = useState(State.Loading); + + const counter = useRef(null); + + const load = async () => { + try { + setState(State.Loading); + await p.load(); + setState(State.Ready); + } catch (e) { + console.error(e); + setState(State.Error); + } + }; + + useEffect(() => { + if (counter.current === p.loadKey) return; + counter.current = p.loadKey; + + load(); + }); + + if (state === State.Error) + return ( + + theme.palette.mode === "light" + ? theme.palette.grey[100] + : theme.palette.grey[900], + }} + > + + {p.errMsg} + + + + + {p.errAdditionalElement && p.errAdditionalElement()} + + ); + + if (state === State.Loading || p.ready === false) + return ( + + theme.palette.mode === "light" + ? theme.palette.grey[100] + : theme.palette.grey[900], + }} + > + + + ); + + return p.build(); +}