diff --git a/virtweb_backend/Cargo.lock b/virtweb_backend/Cargo.lock index 3c80db5..18c6836 100644 --- a/virtweb_backend/Cargo.lock +++ b/virtweb_backend/Cargo.lock @@ -1224,8 +1224,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1596,6 +1598,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" @@ -2042,6 +2059,16 @@ 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 = "percent-encoding" version = "2.3.1" @@ -2376,6 +2403,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" @@ -2620,6 +2662,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" @@ -3020,6 +3074,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" @@ -3137,6 +3197,7 @@ dependencies = [ "futures-util", "image", "ipnetwork", + "jsonwebtoken", "lazy-regex", "lazy_static", "light-openid", @@ -3144,9 +3205,11 @@ dependencies = [ "mime_guess", "nix", "num", + "pem", "quick-xml", "rand", "reqwest", + "ring", "rust-embed", "serde", "serde_json", diff --git a/virtweb_backend/Cargo.toml b/virtweb_backend/Cargo.toml index 5fb5ae8..08e7383 100644 --- a/virtweb_backend/Cargo.toml +++ b/virtweb_backend/Cargo.toml @@ -45,3 +45,6 @@ rust-embed = { version = "8.3.0" } mime_guess = "2.0.4" dotenvy = "0.15.7" nix = { version = "0.28.0", features = ["net"] } +jsonwebtoken = "9.3.0" +ring = "0.17.8" +pem = "3.0.4" \ No newline at end of file diff --git a/virtweb_backend/src/api_tokens.rs b/virtweb_backend/src/api_tokens.rs new file mode 100644 index 0000000..8ec405b --- /dev/null +++ b/virtweb_backend/src/api_tokens.rs @@ -0,0 +1,105 @@ +//! # API tokens management + +use crate::constants; +use crate::utils::jwt_utils; +use crate::utils::jwt_utils::{TokenPrivKey, TokenPubKey}; +use crate::utils::time_utils::time; + +#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug)] +pub struct TokenID(pub uuid::Uuid); + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +pub struct Token { + pub name: String, + pub description: String, + pub id: TokenID, + created: u64, + updated: u64, + pub pub_key: TokenPubKey, + pub rights: Vec, + pub last_used: Option, + pub ip_restriction: Option, + pub delete_after_inactivity: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Eq, PartialEq)] +pub enum TokenVerb { + GET, + POST, + PUT, + PATCH, + DELETE, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +pub struct TokenRights { + verb: TokenVerb, + uri: String, +} + +/// Structure used to create a token +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +pub struct NewToken { + pub name: String, + pub description: String, + pub rights: Vec, + pub ip_restriction: Option, + pub delete_after_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!"); + } + + for r in &self.rights { + if !r.uri.starts_with("/api/") { + return Some("All API rights shall start with /api/"); + } + } + + if let Some(t) = self.delete_after_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(token: &NewToken) -> anyhow::Result<(Token, TokenPrivKey)> { + let (pub_key, priv_key) = jwt_utils::generate_key_pair()?; + + let full_token = Token { + name: token.name.to_string(), + description: token.description.to_string(), + id: TokenID(uuid::Uuid::new_v4()), + created: time(), + updated: time(), + pub_key, + rights: token.rights.clone(), + last_used: Some(time()), + ip_restriction: token.ip_restriction, + delete_after_inactivity: token.delete_after_inactivity, + }; + + // TODO : save + + Ok((full_token, priv_key)) +} diff --git a/virtweb_backend/src/app_config.rs b/virtweb_backend/src/app_config.rs index 77f74ab..bfa577e 100644 --- a/virtweb_backend/src/app_config.rs +++ b/virtweb_backend/src/app_config.rs @@ -268,6 +268,10 @@ 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) + } } #[derive(Debug, Clone, serde::Serialize)] diff --git a/virtweb_backend/src/constants.rs b/virtweb_backend/src/constants.rs index 7942fcf..6cfbe9b 100644 --- a/virtweb_backend/src/constants.rs +++ b/virtweb_backend/src/constants.rs @@ -89,3 +89,18 @@ 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; 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..e36a6b7 --- /dev/null +++ b/virtweb_backend/src/controllers/api_tokens_controller.rs @@ -0,0 +1,49 @@ +//! # API tokens management + +use crate::api_tokens; +use crate::api_tokens::NewToken; +use crate::controllers::api_tokens_controller::rest_token::RestToken; +use crate::controllers::HttpResult; +use crate::utils::jwt_utils::TokenPrivKey; +use actix_web::{web, HttpResponse}; + +/// Create a special module for REST token to enforce usage of constructor function +mod rest_token { + use crate::api_tokens::Token; + use crate::utils::jwt_utils::TokenPubKey; + + #[derive(serde::Serialize)] + pub struct RestToken { + token: Token, + } + + impl RestToken { + pub fn new(mut token: Token) -> Self { + token.pub_key = TokenPubKey::None; + Self { token } + } + } +} + +#[derive(serde::Serialize)] +struct CreateTokenResult { + token: RestToken, + priv_key: TokenPrivKey, +} + +/// 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, + })) +} 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..1e729ed 100644 --- a/virtweb_backend/src/controllers/server_controller.rs +++ b/virtweb_backend/src/controllers/server_controller.rs @@ -51,6 +51,8 @@ struct ServerConstraints { nwfilter_comment_size: LenConstraints, nwfilter_priority: SLenConstraints, nwfilter_selectors_count: LenConstraints, + api_token_name_size: LenConstraints, + api_token_description_size: LenConstraints, } pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { @@ -98,6 +100,16 @@ 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, + }, }, }) } 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..c2db550 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() @@ -276,6 +277,27 @@ async fn main() -> std::io::Result<()> { "/api/nwfilter/{uid}", web::delete().to(nwfilter_controller::delete), ) + // API tokens controller + .route( + "/api/tokens/create", + web::post().to(api_tokens_controller::create), + ) + /* TODO .route( + "/api/tokens/list", + web::get().to(api_tokens_controller::list), + ) + .route( + "/api/tokens/{uid}", + web::get().to(api_tokens_controller::get_single), + ) + .route( + "/api/tokens/{uid}", + web::put().to(api_tokens_controller::update), + ) + .route( + "/api/tokens/{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/utils/jwt_utils.rs b/virtweb_backend/src/utils/jwt_utils.rs new file mode 100644 index 0000000..67819c0 --- /dev/null +++ b/virtweb_backend/src/utils/jwt_utils.rs @@ -0,0 +1,109 @@ +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Validation}; +use ring::signature::{KeyPair, UnparsedPublicKey}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +#[serde(tag = "alg")] +pub enum TokenPubKey { + /// This variant DOES make crash the program. It MUST NOT be serialized. + /// + /// It is a hack to hide public key when getting the list of tokens + None, + + /// ECDSA with SHA2-384 variant + ES384 { r#pub: String }, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +#[serde(tag = "alg")] +pub enum TokenPrivKey { + ES384 { r#priv: String }, +} + +/// Generate a new token keypair +pub fn generate_key_pair() -> anyhow::Result<(TokenPubKey, TokenPrivKey)> { + let doc = ring::signature::EcdsaKeyPair::generate_pkcs8( + &ring::signature::ECDSA_P384_SHA384_ASN1_SIGNING, + &ring::rand::SystemRandom::new(), + )?; + + let priv_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", doc.as_ref())); + + let pair = ring::signature::EcdsaKeyPair::from_pkcs8( + &ring::signature::ECDSA_P384_SHA384_ASN1_SIGNING, + doc.as_ref(), + &ring::rand::SystemRandom::new(), + )?; + let pub_pem = pem::encode(&pem::Pem::new("PUBLIC KEY", pair.public_key().as_ref())); + + + let pk = pair.public_key(); + let unp = UnparsedPublicKey::new(&ring::signature::ECDSA_P384_SHA384_ASN1_SIGNING, pk.as_ref()); + + let decoding_key = DecodingKey::from_ec_pem(pub_pem.as_bytes()).expect("aie ai"); + + Ok(( + TokenPubKey::ES384 { r#pub: pub_pem }, + TokenPrivKey::ES384 { r#priv: priv_pem }, + )) +} + +/// Sign JWT with a private key +pub fn sign_jwt(key: &TokenPrivKey, claims: &C) -> anyhow::Result { + match key { + TokenPrivKey::ES384 { r#priv } => { + let encoding_key = EncodingKey::from_ec_pem(r#priv.as_bytes())?; + + Ok(jsonwebtoken::encode( + &jsonwebtoken::Header::new(Algorithm::ES384), + &claims, + &encoding_key, + )?) + } + } +} + +/// Validate a given JWT +pub fn validate_jwt(key: &TokenPubKey, token: &str) -> anyhow::Result { + match key { + TokenPubKey::ES384 { r#pub } => { + let decoding_key = DecodingKey::from_ec_pem(r#pub.as_bytes())?; + + let validation = Validation::new(Algorithm::ES384); + Ok(jsonwebtoken::decode::(token, &decoding_key, &validation)?.claims) + } + TokenPubKey::None => { + panic!("A public key is required!") + } + } +} + +#[cfg(test)] +mod test { + use crate::utils::jwt_utils::{generate_key_pair, sign_jwt, validate_jwt}; + use crate::utils::time_utils::time; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] + pub struct Claims { + sub: String, + exp: u64, + } + + #[test] + fn jwt_encode_sign_verify_valid() { + let (pub_key, priv_key) = generate_key_pair().unwrap(); + let claims = Claims { + sub: "my-sub".to_string(), + exp: time() + 100, + }; + let jwt = sign_jwt(&priv_key, &claims).expect("Failed to sign JWT!"); + + println!("pub {pub_key:?}"); + println!("priv {priv_key:?}"); + let claims_out = validate_jwt(&pub_key, &jwt).expect("Failed to validate JWT!"); + + assert_eq!(claims, claims_out) + } +} diff --git a/virtweb_backend/src/utils/mod.rs b/virtweb_backend/src/utils/mod.rs index f10b3e2..2c0528e 100644 --- a/virtweb_backend/src/utils/mod.rs +++ b/virtweb_backend/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod disks_utils; pub mod files_utils; +pub mod jwt_utils; pub mod net_utils; pub mod rand_utils; pub mod time_utils;