Add API tokens support #9
63
virtweb_backend/Cargo.lock
generated
63
virtweb_backend/Cargo.lock
generated
@ -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",
|
||||
|
@ -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"
|
105
virtweb_backend/src/api_tokens.rs
Normal file
105
virtweb_backend/src/api_tokens.rs
Normal file
@ -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<TokenRights>,
|
||||
pub last_used: Option<u64>,
|
||||
pub ip_restriction: Option<ipnetwork::IpNetwork>,
|
||||
pub delete_after_inactivity: Option<u64>,
|
||||
}
|
||||
|
||||
#[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<TokenRights>,
|
||||
pub ip_restriction: Option<ipnetwork::IpNetwork>,
|
||||
pub delete_after_inactivity: Option<u64>,
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
@ -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)]
|
||||
|
@ -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;
|
||||
|
49
virtweb_backend/src/controllers/api_tokens_controller.rs
Normal file
49
virtweb_backend/src/controllers/api_tokens_controller.rs
Normal file
@ -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<NewToken>) -> 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,
|
||||
}))
|
||||
}
|
@ -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;
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
pub mod actors;
|
||||
pub mod api_tokens;
|
||||
pub mod app_config;
|
||||
pub mod constants;
|
||||
pub mod controllers;
|
||||
|
@ -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(
|
||||
|
109
virtweb_backend/src/utils/jwt_utils.rs
Normal file
109
virtweb_backend/src/utils/jwt_utils.rs
Normal file
@ -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<C: Serialize>(key: &TokenPrivKey, claims: &C) -> anyhow::Result<String> {
|
||||
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<E: DeserializeOwned>(key: &TokenPubKey, token: &str) -> anyhow::Result<E> {
|
||||
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::<E>(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)
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user