diff --git a/virtweb_backend/Cargo.lock b/virtweb_backend/Cargo.lock index ffb9f57..fe77ff3 100644 --- a/virtweb_backend/Cargo.lock +++ b/virtweb_backend/Cargo.lock @@ -568,6 +568,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.20.0" @@ -586,6 +592,26 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "basic-jwt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741afb780192f091b1ceebdc794540a956f3eb96628939f83c5d15e0cb98fa71" +dependencies = [ + "anyhow", + "elliptic-curve", + "jsonwebtoken", + "p384", + "rand", + "serde", +] + [[package]] name = "bincode" version = "2.0.0-rc.3" @@ -792,6 +818,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.4.0" @@ -890,6 +922,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -945,6 +989,17 @@ dependencies = [ "syn 2.0.58", ] +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.3.11" @@ -974,6 +1029,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -984,12 +1040,47 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.33" @@ -1069,6 +1160,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "flate2" version = "1.0.28" @@ -1215,6 +1316,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1224,8 +1326,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1254,6 +1358,17 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.3.26" @@ -1596,6 +1711,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" +dependencies = [ + "base64 0.21.7", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -2007,6 +2137,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "p384" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70786f51bcc69f6a4c0360e063a4cac5419ef7c5cd5b3c99ad70f3be5ba79209" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.1" @@ -2042,6 +2184,25 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.0", + "serde", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2080,6 +2241,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.30" @@ -2123,6 +2294,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.79" @@ -2367,6 +2547,16 @@ dependencies = [ "winreg", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rgb" version = "0.8.37" @@ -2376,6 +2566,21 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rust-embed" version = "8.3.0" @@ -2484,6 +2689,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.10.0" @@ -2515,18 +2734,18 @@ checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" [[package]] name = "serde" -version = "1.0.197" +version = "1.0.198" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.198" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" dependencies = [ "proc-macro2", "quote", @@ -2605,6 +2824,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -2620,6 +2849,18 @@ dependencies = [ "quote", ] +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.9" @@ -2654,6 +2895,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "strsim" version = "0.10.0" @@ -3020,6 +3271,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.0" @@ -3129,6 +3386,7 @@ dependencies = [ "actix-web", "actix-web-actors", "anyhow", + "basic-jwt", "bytes", "clap", "dotenvy", @@ -3500,6 +3758,12 @@ dependencies = [ "syn 2.0.58", ] +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + [[package]] name = "zstd" version = "0.13.1" diff --git a/virtweb_backend/Cargo.toml b/virtweb_backend/Cargo.toml index 3280ee5..45d34a7 100644 --- a/virtweb_backend/Cargo.toml +++ b/virtweb_backend/Cargo.toml @@ -45,3 +45,4 @@ rust-embed = { version = "8.3.0" } mime_guess = "2.0.4" dotenvy = "0.15.7" nix = { version = "0.28.0", features = ["net"] } +basic-jwt = "0.2.0" \ No newline at end of file diff --git a/virtweb_backend/examples/api_curl.rs b/virtweb_backend/examples/api_curl.rs new file mode 100644 index 0000000..a13cd93 --- /dev/null +++ b/virtweb_backend/examples/api_curl.rs @@ -0,0 +1,66 @@ +use basic_jwt::JWTPrivateKey; +use clap::Parser; +use std::os::unix::prelude::CommandExt; +use std::process::Command; +use std::str::FromStr; +use virtweb_backend::api_tokens::TokenVerb; +use virtweb_backend::extractors::api_auth_extractor::TokenClaims; +use virtweb_backend::utils::time_utils::time; + +/// cURL wrapper to query Virtweb backend API +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + /// URL of VirtWeb + #[arg(short('u'), long, env, default_value = "http://localhost:8000")] + virtweb_url: String, + + /// Token ID + #[arg(short('i'), long, env)] + token_id: String, + + /// Token private key + #[arg(short('t'), long, env)] + token_key: String, + + /// Request verb + #[arg(short('X'), long, default_value = "GET")] + verb: String, + + /// Request URI + uri: String, + + /// Command line arguments to pass to cURL + #[clap(trailing_var_arg = true, allow_hyphen_values = true)] + run: Vec, +} + +fn main() { + let args = Args::parse(); + + let full_url = format!("{}{}", args.virtweb_url, args.uri); + log::debug!("Full URL: {full_url}"); + + let key = JWTPrivateKey::ES384 { + r#priv: args.token_key, + }; + let claims = TokenClaims { + sub: args.token_id.to_string(), + iat: time() as usize, + exp: time() as usize + 50, + verb: TokenVerb::from_str(&args.verb).expect("Invalid request verb!"), + path: args.uri, + nonce: uuid::Uuid::new_v4().to_string(), + }; + + let jwt = key.sign_jwt(&claims).expect("Failed to sign JWT!"); + + Command::new("curl") + .args(["-X", &args.verb]) + .args(["-H", &format!("x-token-id: {}", args.token_id)]) + .args(["-H", &format!("x-token-content: {jwt}")]) + .args(args.run) + .arg(full_url) + .exec(); + panic!("Failed to run curl!") +} diff --git a/virtweb_backend/src/api_tokens.rs b/virtweb_backend/src/api_tokens.rs new file mode 100644 index 0000000..8833767 --- /dev/null +++ b/virtweb_backend/src/api_tokens.rs @@ -0,0 +1,299 @@ +//! # API tokens management + +use crate::app_config::AppConfig; +use crate::constants; +use crate::utils::time_utils::time; +use actix_http::Method; +use basic_jwt::{JWTPrivateKey, JWTPublicKey}; +use std::path::Path; +use std::str::FromStr; + +#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug)] +pub struct TokenID(pub uuid::Uuid); + +impl TokenID { + /// Parse a string as a token id + pub fn parse(t: &str) -> anyhow::Result { + Ok(Self(uuid::Uuid::parse_str(t)?)) + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] +pub struct TokenRight { + verb: TokenVerb, + path: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +pub struct TokenRights(Vec); + +impl TokenRights { + pub fn check_error(&self) -> Option<&'static str> { + for r in &self.0 { + if !r.path.starts_with("/api/") { + return Some("All API rights shall start with /api/"); + } + + if r.path.len() > constants::API_TOKEN_RIGHT_PATH_MAX_LENGTH { + return Some("An API path shall not exceed maximum URL size!"); + } + } + None + } + + pub fn contains(&self, verb: TokenVerb, path: &str) -> bool { + let req_path_split = path.split('/').collect::>(); + + 'root: for r in &self.0 { + if r.verb != verb { + continue 'root; + } + + let mut last_idx = 0; + for (idx, part) in r.path.split('/').enumerate() { + if idx >= req_path_split.len() { + continue 'root; + } + + if part != "*" && part != req_path_split[idx] { + continue 'root; + } + + last_idx = idx; + } + + // Check we visited the whole path + if last_idx + 1 == req_path_split.len() { + return true; + } + } + + false + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +pub struct Token { + pub id: TokenID, + pub name: String, + pub description: String, + created: u64, + updated: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub pub_key: Option, + pub rights: TokenRights, + pub last_used: u64, + pub ip_restriction: Option, + pub max_inactivity: Option, +} + +impl Token { + /// Turn the token into a JSON string + fn save(&self) -> anyhow::Result<()> { + let json = serde_json::to_string(self)?; + + std::fs::write(AppConfig::get().api_token_definition_path(self.id), json)?; + + Ok(()) + } + + /// Load token information from a file + fn load_from_file(path: &Path) -> anyhow::Result { + Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?) + } + + /// Check whether a token is expired or not + pub fn is_expired(&self) -> bool { + if let Some(max_inactivity) = self.max_inactivity { + if max_inactivity + self.last_used < time() { + return true; + } + } + + false + } + + /// Check whether last_used shall be updated or not + pub fn should_update_last_activity(&self) -> bool { + self.last_used + 3600 < time() + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Eq, PartialEq)] +pub enum TokenVerb { + GET, + POST, + PUT, + PATCH, + DELETE, +} + +impl TokenVerb { + pub fn as_method(&self) -> Method { + match self { + TokenVerb::GET => Method::GET, + TokenVerb::POST => Method::POST, + TokenVerb::PUT => Method::PUT, + TokenVerb::PATCH => Method::PATCH, + TokenVerb::DELETE => Method::DELETE, + } + } +} + +impl FromStr for TokenVerb { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "GET" => Ok(TokenVerb::GET), + "POST" => Ok(TokenVerb::POST), + "PUT" => Ok(TokenVerb::PUT), + "PATCH" => Ok(TokenVerb::PATCH), + "DELETE" => Ok(TokenVerb::DELETE), + _ => Err(()), + } + } +} + +/// Structure used to create a token +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +pub struct NewToken { + pub name: String, + pub description: String, + pub rights: TokenRights, + pub ip_restriction: Option, + pub max_inactivity: Option, +} + +impl NewToken { + /// Check for error in token + pub fn check_error(&self) -> Option<&'static str> { + if self.name.len() < constants::API_TOKEN_NAME_MIN_LENGTH { + return Some("Name is too short!"); + } + + if self.name.len() > constants::API_TOKEN_NAME_MAX_LENGTH { + return Some("Name is too long!"); + } + + if self.description.len() < constants::API_TOKEN_DESCRIPTION_MIN_LENGTH { + return Some("Description is too short!"); + } + + if self.description.len() > constants::API_TOKEN_DESCRIPTION_MAX_LENGTH { + return Some("Description is too long!"); + } + + if let Some(err) = self.rights.check_error() { + return Some(err); + } + + if let Some(t) = self.max_inactivity { + if t < 3600 { + return Some("API tokens shall be valid for at least 1 hour!"); + } + } + + None + } +} + +/// Create a new Token +pub async fn create(t: &NewToken) -> anyhow::Result<(Token, JWTPrivateKey)> { + let priv_key = JWTPrivateKey::generate_ec384_signing_key()?; + let pub_key = priv_key.to_public_key()?; + + let token = Token { + name: t.name.to_string(), + description: t.description.to_string(), + id: TokenID(uuid::Uuid::new_v4()), + created: time(), + updated: time(), + pub_key: Some(pub_key), + rights: t.rights.clone(), + last_used: time(), + ip_restriction: t.ip_restriction, + max_inactivity: t.max_inactivity, + }; + + token.save()?; + + Ok((token, priv_key)) +} + +/// Get the entire list of api tokens +pub async fn full_list() -> anyhow::Result> { + let mut list = Vec::new(); + for f in std::fs::read_dir(AppConfig::get().api_tokens_path())? { + list.push(Token::load_from_file(&f?.path())?); + } + Ok(list) +} + +/// Get the information about a single token +pub async fn get_single(id: TokenID) -> anyhow::Result { + Token::load_from_file(&AppConfig::get().api_token_definition_path(id)) +} + +/// Update API tokens rights +pub async fn update_rights(id: TokenID, rights: TokenRights) -> anyhow::Result<()> { + let mut token = get_single(id).await?; + token.rights = rights; + token.updated = time(); + token.save()?; + Ok(()) +} + +/// Set last_used value of token +pub async fn refresh_last_used(id: TokenID) -> anyhow::Result<()> { + let mut token = get_single(id).await?; + token.last_used = time(); + token.save()?; + Ok(()) +} + +/// Delete an API token +pub async fn delete(id: TokenID) -> anyhow::Result<()> { + let path = AppConfig::get().api_token_definition_path(id); + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(()) +} + +#[cfg(test)] +mod test { + use crate::api_tokens::{TokenRight, TokenRights, TokenVerb}; + + #[test] + fn test_rights_patch() { + let rights = TokenRights(vec![ + TokenRight { + path: "/api/vm/*".to_string(), + verb: TokenVerb::GET, + }, + TokenRight { + path: "/api/vm/a".to_string(), + verb: TokenVerb::PUT, + }, + TokenRight { + path: "/api/vm/a/other".to_string(), + verb: TokenVerb::DELETE, + }, + TokenRight { + path: "/api/net/create".to_string(), + verb: TokenVerb::POST, + }, + ]); + + assert!(rights.contains(TokenVerb::GET, "/api/vm/ab")); + assert!(!rights.contains(TokenVerb::GET, "/api/vm")); + assert!(!rights.contains(TokenVerb::GET, "/api/vm/ab/c")); + assert!(rights.contains(TokenVerb::PUT, "/api/vm/a")); + assert!(!rights.contains(TokenVerb::PUT, "/api/vm/other")); + assert!(rights.contains(TokenVerb::POST, "/api/net/create")); + assert!(!rights.contains(TokenVerb::GET, "/api/net/create")); + assert!(!rights.contains(TokenVerb::POST, "/api/net/b")); + assert!(!rights.contains(TokenVerb::POST, "/api/net/create/b")); + } +} diff --git a/virtweb_backend/src/app_config.rs b/virtweb_backend/src/app_config.rs index 77f74ab..16780b6 100644 --- a/virtweb_backend/src/app_config.rs +++ b/virtweb_backend/src/app_config.rs @@ -1,3 +1,4 @@ +use crate::api_tokens::TokenID; use crate::constants; use crate::libvirt_lib_structures::XMLUuid; use crate::libvirt_rest_structures::net::NetworkName; @@ -268,6 +269,14 @@ impl AppConfig { self.definitions_path() .join(format!("nwfilter-{}.json", name.0)) } + + pub fn api_tokens_path(&self) -> PathBuf { + self.storage_path().join(constants::STORAGE_TOKENS_DIR) + } + + pub fn api_token_definition_path(&self, id: TokenID) -> PathBuf { + self.api_tokens_path().join(format!("{}.json", id.0)) + } } #[derive(Debug, Clone, serde::Serialize)] diff --git a/virtweb_backend/src/constants.rs b/virtweb_backend/src/constants.rs index 7942fcf..381b6e2 100644 --- a/virtweb_backend/src/constants.rs +++ b/virtweb_backend/src/constants.rs @@ -89,3 +89,21 @@ pub const NAT_MODE_ENV_VAR_NAME: &str = "NAT_MODE"; /// Nat hook file path pub const NAT_HOOK_PATH: &str = "/etc/libvirt/hooks/network"; + +/// Directory where API tokens are stored, inside storage directory +pub const STORAGE_TOKENS_DIR: &str = "tokens"; + +/// API token name min length +pub const API_TOKEN_NAME_MIN_LENGTH: usize = 3; + +/// API token name max length +pub const API_TOKEN_NAME_MAX_LENGTH: usize = 30; + +/// API token description min length +pub const API_TOKEN_DESCRIPTION_MIN_LENGTH: usize = 5; + +/// API token description max length +pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30; + +/// API token right path max length +pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255; diff --git a/virtweb_backend/src/controllers/api_tokens_controller.rs b/virtweb_backend/src/controllers/api_tokens_controller.rs new file mode 100644 index 0000000..a1f2a88 --- /dev/null +++ b/virtweb_backend/src/controllers/api_tokens_controller.rs @@ -0,0 +1,100 @@ +//! # API tokens management + +use crate::api_tokens; +use crate::api_tokens::{NewToken, TokenID, TokenRights}; +use crate::controllers::api_tokens_controller::rest_token::RestToken; +use crate::controllers::HttpResult; +use actix_web::{web, HttpResponse}; +use basic_jwt::JWTPrivateKey; + +/// Create a special module for REST token to enforce usage of constructor function +mod rest_token { + use crate::api_tokens::Token; + + #[derive(serde::Serialize)] + pub struct RestToken { + #[serde(flatten)] + token: Token, + } + + impl RestToken { + pub fn new(mut token: Token) -> Self { + token.pub_key = None; + Self { token } + } + } +} + +#[derive(serde::Serialize)] +struct CreateTokenResult { + token: RestToken, + priv_key: JWTPrivateKey, +} + +/// Create a new API token +pub async fn create(new_token: web::Json) -> HttpResult { + if let Some(err) = new_token.check_error() { + log::error!("Failed to validate new API token information! {err}"); + return Ok(HttpResponse::BadRequest().json(format!( + "Failed to validate new API token information! {err}" + ))); + } + + let (token, priv_key) = api_tokens::create(&new_token).await?; + + Ok(HttpResponse::Ok().json(CreateTokenResult { + token: RestToken::new(token), + priv_key, + })) +} + +/// Get the list of API tokens +pub async fn list() -> HttpResult { + let list = api_tokens::full_list() + .await? + .into_iter() + .map(RestToken::new) + .collect::>(); + + Ok(HttpResponse::Ok().json(list)) +} + +#[derive(serde::Deserialize)] +pub struct TokenIDInPath { + uid: TokenID, +} + +/// Get the information about a single token +pub async fn get_single(path: web::Path) -> HttpResult { + let token = api_tokens::get_single(path.uid).await?; + + Ok(HttpResponse::Ok().json(RestToken::new(token))) +} + +#[derive(serde::Deserialize)] +pub struct UpdateTokenBody { + rights: TokenRights, +} + +/// Update a token +pub async fn update( + path: web::Path, + body: web::Json, +) -> HttpResult { + if let Some(err) = body.rights.check_error() { + log::error!("Failed to validate updated API token information! {err}"); + return Ok(HttpResponse::BadRequest() + .json(format!("Failed to validate API token information! {err}"))); + } + + api_tokens::update_rights(path.uid, body.0.rights).await?; + + Ok(HttpResponse::Accepted().finish()) +} + +/// Delete a token +pub async fn delete(path: web::Path) -> HttpResult { + api_tokens::delete(path.uid).await?; + + Ok(HttpResponse::Accepted().finish()) +} diff --git a/virtweb_backend/src/controllers/mod.rs b/virtweb_backend/src/controllers/mod.rs index 402d2d4..7c41010 100644 --- a/virtweb_backend/src/controllers/mod.rs +++ b/virtweb_backend/src/controllers/mod.rs @@ -6,6 +6,7 @@ use std::error::Error; use std::fmt::{Display, Formatter}; use std::io::ErrorKind; +pub mod api_tokens_controller; pub mod auth_controller; pub mod iso_controller; pub mod network_controller; diff --git a/virtweb_backend/src/controllers/server_controller.rs b/virtweb_backend/src/controllers/server_controller.rs index 0310e24..0f83021 100644 --- a/virtweb_backend/src/controllers/server_controller.rs +++ b/virtweb_backend/src/controllers/server_controller.rs @@ -51,6 +51,9 @@ struct ServerConstraints { nwfilter_comment_size: LenConstraints, nwfilter_priority: SLenConstraints, nwfilter_selectors_count: LenConstraints, + api_token_name_size: LenConstraints, + api_token_description_size: LenConstraints, + api_token_right_path_size: LenConstraints, } pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { @@ -98,6 +101,21 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { max: 1000, }, nwfilter_selectors_count: LenConstraints { min: 0, max: 1 }, + + api_token_name_size: LenConstraints { + min: constants::API_TOKEN_NAME_MIN_LENGTH, + max: constants::API_TOKEN_NAME_MAX_LENGTH, + }, + + api_token_description_size: LenConstraints { + min: constants::API_TOKEN_DESCRIPTION_MIN_LENGTH, + max: constants::API_TOKEN_DESCRIPTION_MAX_LENGTH, + }, + + api_token_right_path_size: LenConstraints { + min: 0, + max: constants::API_TOKEN_RIGHT_PATH_MAX_LENGTH, + }, }, }) } diff --git a/virtweb_backend/src/extractors/api_auth_extractor.rs b/virtweb_backend/src/extractors/api_auth_extractor.rs new file mode 100644 index 0000000..a86def9 --- /dev/null +++ b/virtweb_backend/src/extractors/api_auth_extractor.rs @@ -0,0 +1,151 @@ +use crate::api_tokens::{Token, TokenID, TokenVerb}; + +use crate::api_tokens; +use crate::utils::time_utils::time; +use actix_remote_ip::RemoteIP; +use actix_web::dev::Payload; +use actix_web::error::{ErrorBadRequest, ErrorUnauthorized}; +use actix_web::{Error, FromRequest, HttpRequest}; +use std::future::Future; +use std::pin::Pin; + +#[derive(serde::Serialize, serde::Deserialize, Debug)] +pub struct TokenClaims { + pub sub: String, + pub iat: usize, + pub exp: usize, + pub verb: TokenVerb, + pub path: String, + pub nonce: String, +} + +pub struct ApiAuthExtractor { + pub token: Token, + pub claims: TokenClaims, +} + +impl FromRequest for ApiAuthExtractor { + type Error = Error; + type Future = Pin>>>; + + fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { + let req = req.clone(); + + let remote_ip = match RemoteIP::from_request(&req, payload).into_inner() { + Ok(ip) => ip, + Err(e) => return Box::pin(async { Err(e) }), + }; + + Box::pin(async move { + let (token_id, token_jwt) = match ( + req.headers().get("x-token-id"), + req.headers().get("x-token-content"), + ) { + (Some(id), Some(jwt)) => ( + id.to_str().unwrap_or("").to_string(), + jwt.to_str().unwrap_or("").to_string(), + ), + (_, _) => { + return Err(ErrorBadRequest("API auth headers were not all specified!")); + } + }; + + let token_id = match TokenID::parse(&token_id) { + Ok(t) => t, + Err(e) => { + log::error!("Failed to parse token id! {e}"); + return Err(ErrorBadRequest("Unable to validate token ID!")); + } + }; + + let token = match api_tokens::get_single(token_id).await { + Ok(t) => t, + Err(e) => { + log::error!("Failed to retrieve token: {e}"); + return Err(ErrorBadRequest("Unable to validate token!")); + } + }; + + if token.is_expired() { + log::error!("Token has expired (not been used for too long)!"); + return Err(ErrorBadRequest("Unable to validate token!")); + } + + let claims = match token + .pub_key + .as_ref() + .expect("All tokens shall have public key!") + .validate_jwt::(&token_jwt) + { + Ok(c) => c, + Err(e) => { + log::error!("Failed to validate JWT: {e}"); + return Err(ErrorBadRequest("Unable to validate token!")); + } + }; + + if claims.sub != token.id.0.to_string() { + log::error!("JWT sub mismatch (should equal to token id)!"); + return Err(ErrorBadRequest( + "JWT sub mismatch (should equal to token id)!", + )); + } + + if time() + 60 * 15 < claims.iat as u64 { + log::error!("iat is in the future!"); + return Err(ErrorBadRequest("iat is in the future!")); + } + + if claims.exp < claims.iat { + log::error!("exp shall not be smaller than iat!"); + return Err(ErrorBadRequest("exp shall not be smaller than iat!")); + } + + if claims.exp - claims.iat > 1800 { + log::error!("JWT shall not be valid more than 30 minutes!"); + return Err(ErrorBadRequest( + "JWT shall not be valid more than 30 minutes!", + )); + } + + if claims.path != req.path() { + log::error!("JWT path mismatch!"); + return Err(ErrorBadRequest("JWT path mismatch!")); + } + + if claims.verb.as_method() != req.method() { + log::error!("JWT method mismatch!"); + return Err(ErrorBadRequest("JWT method mismatch!")); + } + + if !token.rights.contains(claims.verb, req.path()) { + log::error!( + "Attempt to use a token for an unauthorized route! (token_id={})", + token.id.0 + ); + return Err(ErrorUnauthorized( + "Token cannot be used to query this route!", + )); + } + + if let Some(ip) = token.ip_restriction { + if !ip.contains(remote_ip.0) { + log::error!( + "Attempt to use a token for an unauthorized IP! {remote_ip:?} token_id={}", + token.id.0 + ); + return Err(ErrorUnauthorized("Token cannot be used from this IP!")); + } + } + + if token.should_update_last_activity() { + if let Err(e) = api_tokens::refresh_last_used(token.id).await { + log::error!("Could not update token last activity! {e}"); + return Err(ErrorBadRequest("Couldn't refresh token last activity!")); + } + } + + Ok(ApiAuthExtractor { token, claims }) + }) + } +} diff --git a/virtweb_backend/src/extractors/mod.rs b/virtweb_backend/src/extractors/mod.rs index d7e49e1..d284d19 100644 --- a/virtweb_backend/src/extractors/mod.rs +++ b/virtweb_backend/src/extractors/mod.rs @@ -1,2 +1,3 @@ +pub mod api_auth_extractor; pub mod auth_extractor; pub mod local_auth_extractor; diff --git a/virtweb_backend/src/lib.rs b/virtweb_backend/src/lib.rs index 356a5e3..078fee9 100644 --- a/virtweb_backend/src/lib.rs +++ b/virtweb_backend/src/lib.rs @@ -1,4 +1,5 @@ pub mod actors; +pub mod api_tokens; pub mod app_config; pub mod constants; pub mod controllers; diff --git a/virtweb_backend/src/main.rs b/virtweb_backend/src/main.rs index d05a110..81ffae9 100644 --- a/virtweb_backend/src/main.rs +++ b/virtweb_backend/src/main.rs @@ -22,8 +22,8 @@ use virtweb_backend::constants::{ MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME, }; use virtweb_backend::controllers::{ - auth_controller, iso_controller, network_controller, nwfilter_controller, server_controller, - static_controller, vm_controller, + api_tokens_controller, auth_controller, iso_controller, network_controller, + nwfilter_controller, server_controller, static_controller, vm_controller, }; use virtweb_backend::libvirt_client::LibVirtClient; use virtweb_backend::middlewares::auth_middleware::AuthChecker; @@ -50,6 +50,7 @@ async fn main() -> std::io::Result<()> { files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap(); files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap(); files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap(); + files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap(); let conn = Data::new(LibVirtClient( LibVirtActor::connect() @@ -84,7 +85,7 @@ async fn main() -> std::io::Result<()> { let mut cors = Cors::default() .allowed_origin(&AppConfig::get().website_origin) - .allowed_methods(vec!["GET", "POST", "DELETE", "PUT"]) + .allowed_methods(vec!["GET", "POST", "DELETE", "PUT", "PATCH"]) .allowed_headers(vec![header::AUTHORIZATION, header::ACCEPT]) .allowed_header(header::CONTENT_TYPE) .supports_credentials() @@ -276,6 +277,27 @@ async fn main() -> std::io::Result<()> { "/api/nwfilter/{uid}", web::delete().to(nwfilter_controller::delete), ) + // API tokens controller + .route( + "/api/token/create", + web::post().to(api_tokens_controller::create), + ) + .route( + "/api/token/list", + web::get().to(api_tokens_controller::list), + ) + .route( + "/api/token/{uid}", + web::get().to(api_tokens_controller::get_single), + ) + .route( + "/api/token/{uid}", + web::patch().to(api_tokens_controller::update), + ) + .route( + "/api/token/{uid}", + web::delete().to(api_tokens_controller::delete), + ) // Static assets .route("/", web::get().to(static_controller::root_index)) .route( diff --git a/virtweb_backend/src/middlewares/auth_middleware.rs b/virtweb_backend/src/middlewares/auth_middleware.rs index f279216..7479b4d 100644 --- a/virtweb_backend/src/middlewares/auth_middleware.rs +++ b/virtweb_backend/src/middlewares/auth_middleware.rs @@ -3,6 +3,7 @@ use std::rc::Rc; use crate::app_config::AppConfig; use crate::constants; +use crate::extractors::api_auth_extractor::ApiAuthExtractor; use crate::extractors::auth_extractor::AuthExtractor; use actix_web::body::EitherBody; use actix_web::dev::Payload; @@ -68,8 +69,28 @@ where let auth_disabled = AppConfig::get().unsecure_disable_auth; - // Check authentication, if required - if !auth_disabled + // Check API authentication + if req.headers().get("x-token-id").is_some() { + let auth = + match ApiAuthExtractor::from_request(req.request(), &mut Payload::None).await { + Ok(auth) => auth, + Err(e) => { + log::error!( + "Failed to extract API authentication information from request! {e}" + ); + return Ok(req + .into_response(HttpResponse::PreconditionFailed().finish()) + .map_into_right_body()); + } + }; + + log::info!( + "Using API token '{}' to perform the request", + auth.token.name + ); + } + // Check user authentication, if required + else if !auth_disabled && !constants::ROUTES_WITHOUT_AUTH.contains(&req.path()) && req.path().starts_with("/api/") { diff --git a/virtweb_frontend/package-lock.json b/virtweb_frontend/package-lock.json index 1ccdb12..591af9f 100644 --- a/virtweb_frontend/package-lock.json +++ b/virtweb_frontend/package-lock.json @@ -27,6 +27,7 @@ "@types/react-syntax-highlighter": "^15.5.11", "@types/uuid": "^9.0.5", "@vitejs/plugin-react": "^4.2.1", + "date-and-time": "^3.1.1", "filesize": "^10.0.12", "humanize-duration": "^3.29.0", "mui-file-input": "^4.0.4", @@ -35,7 +36,7 @@ "react-router-dom": "^6.15.0", "react-syntax-highlighter": "^15.5.0", "react-vnc": "^1.0.0", - "typescript": "^4.1.6", + "typescript": "^4.0.0", "uuid": "^9.0.1", "vite": "^5.0.8", "vite-tsconfig-paths": "^4.2.2", @@ -8877,6 +8878,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-and-time": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.1.1.tgz", + "integrity": "sha512-N9kstidT3P0VUk1iKOFilOZ6251r6iTUNx9M9kvgL2jqOk9mljWZUq5CjAtYwCnppWHbERk5YFQUrSbY7FQOpA==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -22232,9 +22238,9 @@ } }, "node_modules/vite-tsconfig-paths/node_modules/typescript": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz", - "integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "optional": true, "peer": true, "bin": { diff --git a/virtweb_frontend/package.json b/virtweb_frontend/package.json index 1c4f96d..63cf182 100644 --- a/virtweb_frontend/package.json +++ b/virtweb_frontend/package.json @@ -23,6 +23,7 @@ "@types/react-syntax-highlighter": "^15.5.11", "@types/uuid": "^9.0.5", "@vitejs/plugin-react": "^4.2.1", + "date-and-time": "^3.1.1", "filesize": "^10.0.12", "humanize-duration": "^3.29.0", "mui-file-input": "^4.0.4", @@ -31,7 +32,7 @@ "react-router-dom": "^6.15.0", "react-syntax-highlighter": "^15.5.0", "react-vnc": "^1.0.0", - "typescript": "^5.0.0", + "typescript": "^4.0.0", "uuid": "^9.0.1", "vite": "^5.0.8", "vite-tsconfig-paths": "^4.2.2", diff --git a/virtweb_frontend/src/App.tsx b/virtweb_frontend/src/App.tsx index f4dcc20..8e7cd81 100644 --- a/virtweb_frontend/src/App.tsx +++ b/virtweb_frontend/src/App.tsx @@ -9,29 +9,35 @@ import "./App.css"; import { AuthApi } from "./api/AuthApi"; import { ServerApi } from "./api/ServerApi"; import { - CreateNetworkRoute, - EditNetworkRoute, -} from "./routes/EditNetworkRoute"; -import { CreateVMRoute, EditVMRoute } from "./routes/EditVMRoute"; -import { IsoFilesRoute } from "./routes/IsoFilesRoute"; -import { NetworksListRoute } from "./routes/NetworksListRoute"; -import { NotFoundRoute } from "./routes/NotFound"; -import { SysInfoRoute } from "./routes/SysInfoRoute"; -import { VMListRoute } from "./routes/VMListRoute"; -import { VMRoute } from "./routes/VMRoute"; -import { VNCRoute } from "./routes/VNCRoute"; -import { LoginRoute } from "./routes/auth/LoginRoute"; -import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; -import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; -import { BaseLoginPage } from "./widgets/BaseLoginPage"; -import { ViewNetworkRoute } from "./routes/ViewNetworkRoute"; -import { HomeRoute } from "./routes/HomeRoute"; -import { NetworkFiltersListRoute } from "./routes/NetworkFiltersListRoute"; -import { ViewNWFilterRoute } from "./routes/ViewNWFilterRoute"; + CreateApiTokenRoute, + EditApiTokenRoute, +} from "./routes/EditAPITokenRoute"; import { CreateNWFilterRoute, EditNWFilterRoute, } from "./routes/EditNWFilterRoute"; +import { + CreateNetworkRoute, + EditNetworkRoute, +} from "./routes/EditNetworkRoute"; +import { CreateVMRoute, EditVMRoute } from "./routes/EditVMRoute"; +import { HomeRoute } from "./routes/HomeRoute"; +import { IsoFilesRoute } from "./routes/IsoFilesRoute"; +import { NetworkFiltersListRoute } from "./routes/NetworkFiltersListRoute"; +import { NetworksListRoute } from "./routes/NetworksListRoute"; +import { NotFoundRoute } from "./routes/NotFound"; +import { SysInfoRoute } from "./routes/SysInfoRoute"; +import { TokensListRoute } from "./routes/TokensListRoute"; +import { VMListRoute } from "./routes/VMListRoute"; +import { VMRoute } from "./routes/VMRoute"; +import { VNCRoute } from "./routes/VNCRoute"; +import { ViewApiTokenRoute } from "./routes/ViewApiTokenRoute"; +import { ViewNWFilterRoute } from "./routes/ViewNWFilterRoute"; +import { ViewNetworkRoute } from "./routes/ViewNetworkRoute"; +import { LoginRoute } from "./routes/auth/LoginRoute"; +import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; +import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; +import { BaseLoginPage } from "./widgets/BaseLoginPage"; interface AuthContext { signedIn: boolean; @@ -72,6 +78,11 @@ export function App() { } /> } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/virtweb_frontend/src/api/ServerApi.ts b/virtweb_frontend/src/api/ServerApi.ts index 99e710c..845fd77 100644 --- a/virtweb_frontend/src/api/ServerApi.ts +++ b/virtweb_frontend/src/api/ServerApi.ts @@ -27,6 +27,9 @@ export interface ServerConstraints { nwfilter_comment_size: LenConstraint; nwfilter_priority: LenConstraint; nwfilter_selectors_count: LenConstraint; + api_token_name_size: LenConstraint; + api_token_description_size: LenConstraint; + api_token_right_path_size: LenConstraint; } export interface LenConstraint { diff --git a/virtweb_frontend/src/api/TokensApi.ts b/virtweb_frontend/src/api/TokensApi.ts new file mode 100644 index 0000000..daf27a4 --- /dev/null +++ b/virtweb_frontend/src/api/TokensApi.ts @@ -0,0 +1,102 @@ +import { time } from "../utils/DateUtils"; +import { APIClient } from "./ApiClient"; + +export type RightVerb = "POST" | "GET" | "PUT" | "DELETE" | "PATCH"; + +export interface TokenRight { + verb: RightVerb; + path: string; +} + +export interface APIToken { + id: string; + name: string; + description: string; + created: number; + updated: number; + rights: TokenRight[]; + last_used: number; + ip_restriction?: string; + max_inactivity?: number; +} + +export function APITokenURL(t: APIToken, edit: boolean = false): string { + return `/token/${t.id}${edit ? "/edit" : ""}`; +} + +export function ExpiredAPIToken(t: APIToken): boolean { + if (!t.max_inactivity) return false; + return t.last_used + t.max_inactivity < time(); +} + +export interface APITokenPrivateKey { + alg: string; + priv: string; +} + +export interface CreatedAPIToken { + token: APIToken; + priv_key: APITokenPrivateKey; +} + +export class TokensApi { + /** + * Create a new API token + */ + static async Create(n: APIToken): Promise { + return ( + await APIClient.exec({ + method: "POST", + uri: "/token/create", + jsonData: n, + }) + ).data; + } + + /** + * Get the full list of tokens + */ + static async GetList(): Promise { + return ( + await APIClient.exec({ + method: "GET", + uri: "/token/list", + }) + ).data; + } + + /** + * Get the information about a single token + */ + static async GetSingle(uuid: string): Promise { + return ( + await APIClient.exec({ + method: "GET", + uri: `/token/${uuid}`, + }) + ).data; + } + + /** + * Update an existing API token information + */ + static async Update(n: APIToken): Promise { + return ( + await APIClient.exec({ + method: "PATCH", + uri: `/token/${n.id}`, + jsonData: n, + }) + ).data; + } + + /** + * Delete an API token + */ + static async Delete(n: APIToken): Promise { + await APIClient.exec({ + method: "DELETE", + uri: `/token/${n.id}`, + }); + } +} diff --git a/virtweb_frontend/src/dialogs/CreatedTokenDialog.tsx b/virtweb_frontend/src/dialogs/CreatedTokenDialog.tsx new file mode 100644 index 0000000..ee01993 --- /dev/null +++ b/virtweb_frontend/src/dialogs/CreatedTokenDialog.tsx @@ -0,0 +1,58 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { APITokenURL, CreatedAPIToken } from "../api/TokensApi"; +import { CopyToClipboard } from "../widgets/CopyToClipboard"; +import { InlineCode } from "../widgets/InlineCode"; + +export function CreatedTokenDialog(p: { + createdToken: CreatedAPIToken; +}): React.ReactElement { + const navigate = useNavigate(); + + const close = () => { + navigate(APITokenURL(p.createdToken.token)); + }; + return ( + + Token successfully created + + + Your token was successfully created. You need now to copy the private + key, as it will be technically impossible to recover it after closing + this dialog. + + + + + + + + + + + ); +} + +function InfoBlock( + p: React.PropsWithChildren<{ label: string; value: string }> +): React.ReactElement { + return ( +
+ {p.label} + + {p.value} + +
+ ); +} diff --git a/virtweb_frontend/src/routes/EditAPITokenRoute.tsx b/virtweb_frontend/src/routes/EditAPITokenRoute.tsx new file mode 100644 index 0000000..7063028 --- /dev/null +++ b/virtweb_frontend/src/routes/EditAPITokenRoute.tsx @@ -0,0 +1,161 @@ +import { Button } from "@mui/material"; +import React from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + APIToken, + APITokenURL, + CreatedAPIToken, + TokensApi, +} from "../api/TokensApi"; +import { CreatedTokenDialog } from "../dialogs/CreatedTokenDialog"; +import { useAlert } from "../hooks/providers/AlertDialogProvider"; +import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; +import { useSnackbar } from "../hooks/providers/SnackbarProvider"; +import { time } from "../utils/DateUtils"; +import { AsyncWidget } from "../widgets/AsyncWidget"; +import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; +import { + APITokenDetails, + TokenWidgetStatus, +} from "../widgets/tokens/APITokenDetails"; + +export function CreateApiTokenRoute(): React.ReactElement { + const alert = useAlert(); + const snackbar = useSnackbar(); + const navigate = useNavigate(); + + const [createdToken, setCreatedToken] = React.useState< + CreatedAPIToken | undefined + >(); + + const [token] = React.useState({ + id: "", + name: "", + description: "", + created: time(), + updated: time(), + last_used: time(), + rights: [], + }); + + const createApiToken = async (n: APIToken) => { + try { + const res = await TokensApi.Create(n); + snackbar("The api token was successfully created!"); + setCreatedToken(res); + } catch (e) { + console.error(e); + alert(`Failed to create API token!\n${e}`); + } + }; + + return ( + <> + {createdToken && } + + navigate("/tokens")} + onSave={createApiToken} + /> + + ); +} + +export function EditApiTokenRoute(): React.ReactElement { + const alert = useAlert(); + const snackbar = useSnackbar(); + + const { id } = useParams(); + const navigate = useNavigate(); + + const [token, setToken] = React.useState(); + + const load = async () => { + setToken(await TokensApi.GetSingle(id!)); + }; + + const updateApiToken = async (n: APIToken) => { + try { + await TokensApi.Update(n); + snackbar("The token was successfully updated!"); + navigate(APITokenURL(token!)); + } catch (e) { + console.error(e); + alert(`Failed to update token!\n${e}`); + } + }; + + return ( + ( + navigate(`/token/${id}`)} + onSave={updateApiToken} + /> + )} + /> + ); +} + +function EditApiTokenRouteInner(p: { + token: APIToken; + creating: boolean; + onCancel: () => void; + onSave: (token: APIToken) => Promise; +}): React.ReactElement { + const loadingMessage = useLoadingMessage(); + + const [changed, setChanged] = React.useState(false); + + const [, updateState] = React.useState(); + const forceUpdate = React.useCallback(() => updateState({}), []); + + const valueChanged = () => { + setChanged(true); + forceUpdate(); + }; + + const save = async () => { + loadingMessage.show("Saving API token configuration..."); + await p.onSave(p.token); + loadingMessage.hide(); + }; + + return ( + + {changed && ( + + )} + + + } + > + + + ); +} diff --git a/virtweb_frontend/src/routes/NetworksListRoute.tsx b/virtweb_frontend/src/routes/NetworksListRoute.tsx index 8f7c5d3..a8ef9ce 100644 --- a/virtweb_frontend/src/routes/NetworksListRoute.tsx +++ b/virtweb_frontend/src/routes/NetworksListRoute.tsx @@ -1,4 +1,3 @@ -import DeleteIcon from "@mui/icons-material/Delete"; import VisibilityIcon from "@mui/icons-material/Visibility"; import { Button, @@ -13,13 +12,13 @@ import { Typography, } from "@mui/material"; import React from "react"; +import { useNavigate } from "react-router-dom"; import { NetworkApi, NetworkInfo, NetworkURL } from "../api/NetworksApi"; import { AsyncWidget } from "../widgets/AsyncWidget"; import { RouterLink } from "../widgets/RouterLink"; import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; -import { NetworkStatusWidget } from "../widgets/net/NetworkStatusWidget"; -import { useNavigate } from "react-router-dom"; import { NetworkHookStatusWidget } from "../widgets/net/NetworkHookStatusWidget"; +import { NetworkStatusWidget } from "../widgets/net/NetworkStatusWidget"; export function NetworksListRoute(): React.ReactElement { const [list, setList] = React.useState(); diff --git a/virtweb_frontend/src/routes/TokensListRoute.tsx b/virtweb_frontend/src/routes/TokensListRoute.tsx new file mode 100644 index 0000000..ed162b2 --- /dev/null +++ b/virtweb_frontend/src/routes/TokensListRoute.tsx @@ -0,0 +1,126 @@ +import VisibilityIcon from "@mui/icons-material/Visibility"; +import { + Button, + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material"; +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { + APIToken, + APITokenURL, + ExpiredAPIToken, + TokensApi, +} from "../api/TokensApi"; +import { AsyncWidget } from "../widgets/AsyncWidget"; +import { RouterLink } from "../widgets/RouterLink"; +import { TimeWidget, timeDiff } from "../widgets/TimeWidget"; +import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; + +export function TokensListRoute(): React.ReactElement { + const [list, setList] = React.useState(); + + const [count] = React.useState(1); + + const load = async () => { + setList(await TokensApi.GetList()); + }; + + return ( + } + /> + ); +} + +export function TokensListRouteInner(p: { + list: APIToken[]; +}): React.ReactElement { + const navigate = useNavigate(); + + return ( + + + + } + > + + + + + Name + Description + Created + Updated + Last used + IP restriction + Max inactivity + Rights + Actions + + + + {p.list.map((t) => { + return ( + navigate(APITokenURL(t))} + style={{ backgroundColor: ExpiredAPIToken(t) ? "red" : "" }} + > + + {t.name} {ExpiredAPIToken(t) && (Expired)} + + {t.description} + + + + + + + + + + {t.ip_restriction} + + {t.max_inactivity && timeDiff(0, t.max_inactivity)} + + + {t.rights.map((r) => { + return ( +
+ {r.verb} {r.path} +
+ ); + })} +
+ + + + + + + + +
+ ); + })} +
+
+
+
+ ); +} diff --git a/virtweb_frontend/src/routes/ViewApiTokenRoute.tsx b/virtweb_frontend/src/routes/ViewApiTokenRoute.tsx new file mode 100644 index 0000000..f94e952 --- /dev/null +++ b/virtweb_frontend/src/routes/ViewApiTokenRoute.tsx @@ -0,0 +1,53 @@ +import { Button } from "@mui/material"; +import React from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { APIToken, APITokenURL, TokensApi } from "../api/TokensApi"; +import { AsyncWidget } from "../widgets/AsyncWidget"; +import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; +import { + APITokenDetails, + TokenWidgetStatus, +} from "../widgets/tokens/APITokenDetails"; + +export function ViewApiTokenRoute() { + const { id } = useParams(); + + const [token, setToken] = React.useState(); + + const load = async () => { + setToken(await TokensApi.GetSingle(id!)); + }; + + return ( + } + /> + ); +} + +function ViewAPITokenRouteInner(p: { token: APIToken }): React.ReactElement { + const navigate = useNavigate(); + + return ( + + + + } + > + + + ); +} diff --git a/virtweb_frontend/src/widgets/BaseAuthenticatedPage.tsx b/virtweb_frontend/src/widgets/BaseAuthenticatedPage.tsx index 4af33e4..7969bbd 100644 --- a/virtweb_frontend/src/widgets/BaseAuthenticatedPage.tsx +++ b/virtweb_frontend/src/widgets/BaseAuthenticatedPage.tsx @@ -1,4 +1,5 @@ import { + mdiApi, mdiBoxShadow, mdiDisc, mdiHome, @@ -72,6 +73,11 @@ export function BaseAuthenticatedPage(): React.ReactElement { uri="/iso" icon={} /> + } + /> + {p.children} + + ); +} diff --git a/virtweb_frontend/src/widgets/TimeWidget.tsx b/virtweb_frontend/src/widgets/TimeWidget.tsx new file mode 100644 index 0000000..bc16825 --- /dev/null +++ b/virtweb_frontend/src/widgets/TimeWidget.tsx @@ -0,0 +1,65 @@ +import { Tooltip } from "@mui/material"; +import date from "date-and-time"; +import { time } from "../utils/DateUtils"; + +export function formatDate(time: number): string { + const t = new Date(); + t.setTime(1000 * time); + return date.format(t, "DD/MM/YYYY HH:mm:ss"); +} + +export function timeDiff(a: number, b: number): string { + let diff = b - a; + + if (diff === 0) return "now"; + if (diff === 1) return "1 second"; + + if (diff < 60) { + return `${diff} seconds`; + } + + diff = Math.floor(diff / 60); + + if (diff === 1) return "1 minute"; + if (diff < 24) { + return `${diff} minutes`; + } + + diff = Math.floor(diff / 60); + + if (diff === 1) return "1 hour"; + if (diff < 24) { + return `${diff} hours`; + } + + const diffDays = Math.floor(diff / 24); + + if (diffDays === 1) return "1 day"; + if (diffDays < 31) { + return `${diffDays} days`; + } + + diff = Math.floor(diffDays / 31); + + if (diff < 12) { + return `${diff} month`; + } + + const diffYears = Math.floor(diffDays / 365); + + if (diffYears === 1) return "1 year"; + return `${diffYears} years`; +} + +export function timeDiffFromNow(t: number): string { + return timeDiff(t, time()); +} + +export function TimeWidget(p: { time?: number }): React.ReactElement { + if (!p.time) return <>; + return ( + + {timeDiffFromNow(p.time)} + + ); +} diff --git a/virtweb_frontend/src/widgets/forms/IPInput.tsx b/virtweb_frontend/src/widgets/forms/IPInput.tsx index c7db678..90df36a 100644 --- a/virtweb_frontend/src/widgets/forms/IPInput.tsx +++ b/virtweb_frontend/src/widgets/forms/IPInput.tsx @@ -22,14 +22,16 @@ export function IPInput(p: { export function IPInputWithMask(p: { label: string; editable: boolean; + ipAndMask?: string; ip?: string; mask?: number; - onValueChange?: (ip?: string, mask?: number) => void; + onValueChange?: (ip?: string, mask?: number, ipAndMask?: string) => void; version: 4 | 6; }): React.ReactElement { const showSlash = React.useRef(!!p.mask); const currValue = + p.ipAndMask ?? (p.ip ?? "") + (p.mask || showSlash.current ? "/" : "") + (p.mask ?? ""); const { onValueChange, ...props } = p; @@ -38,7 +40,7 @@ export function IPInputWithMask(p: { onValueChange={(v) => { showSlash.current = false; if (!v) { - onValueChange?.(undefined, undefined); + onValueChange?.(undefined, undefined, undefined); return; } @@ -52,7 +54,11 @@ export function IPInputWithMask(p: { mask = sanitizeMask(p.version, split[1]); } - onValueChange?.(ip, mask); + onValueChange?.( + ip, + mask, + mask || showSlash.current ? `${ip}/${mask ?? ""}` : ip + ); }} value={currValue} {...props} diff --git a/virtweb_frontend/src/widgets/forms/SelectInput.tsx b/virtweb_frontend/src/widgets/forms/SelectInput.tsx index 792a516..21ac0ae 100644 --- a/virtweb_frontend/src/widgets/forms/SelectInput.tsx +++ b/virtweb_frontend/src/widgets/forms/SelectInput.tsx @@ -16,7 +16,7 @@ export interface SelectOption { export function SelectInput(p: { value?: string; editable: boolean; - label: string; + label?: string; options: SelectOption[]; onValueChange: (o?: string) => void; }): React.ReactElement { @@ -29,7 +29,7 @@ export function SelectInput(p: { return ( - {p.label} + {p.label && {p.label}}