Add API tokens support #9

Merged
pierre merged 40 commits from api into master 2024-04-23 17:04:45 +00:00
12 changed files with 387 additions and 2 deletions
Showing only changes of commit ab7907d947 - Show all commits

View File

@ -1224,8 +1224,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1596,6 +1598,21 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "language-tags" name = "language-tags"
version = "0.3.2" version = "0.3.2"
@ -2042,6 +2059,16 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" 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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"
@ -2376,6 +2403,21 @@ dependencies = [
"bytemuck", "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]] [[package]]
name = "rust-embed" name = "rust-embed"
version = "8.3.0" version = "8.3.0"
@ -2620,6 +2662,18 @@ dependencies = [
"quote", "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]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@ -3020,6 +3074,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.0" version = "2.5.0"
@ -3137,6 +3197,7 @@ dependencies = [
"futures-util", "futures-util",
"image", "image",
"ipnetwork", "ipnetwork",
"jsonwebtoken",
"lazy-regex", "lazy-regex",
"lazy_static", "lazy_static",
"light-openid", "light-openid",
@ -3144,9 +3205,11 @@ dependencies = [
"mime_guess", "mime_guess",
"nix", "nix",
"num", "num",
"pem",
"quick-xml", "quick-xml",
"rand", "rand",
"reqwest", "reqwest",
"ring",
"rust-embed", "rust-embed",
"serde", "serde",
"serde_json", "serde_json",

View File

@ -45,3 +45,6 @@ rust-embed = { version = "8.3.0" }
mime_guess = "2.0.4" mime_guess = "2.0.4"
dotenvy = "0.15.7" dotenvy = "0.15.7"
nix = { version = "0.28.0", features = ["net"] } nix = { version = "0.28.0", features = ["net"] }
jsonwebtoken = "9.3.0"
ring = "0.17.8"
pem = "3.0.4"

View 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))
}

View File

@ -268,6 +268,10 @@ impl AppConfig {
self.definitions_path() self.definitions_path()
.join(format!("nwfilter-{}.json", name.0)) .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)] #[derive(Debug, Clone, serde::Serialize)]

View File

@ -89,3 +89,18 @@ pub const NAT_MODE_ENV_VAR_NAME: &str = "NAT_MODE";
/// Nat hook file path /// Nat hook file path
pub const NAT_HOOK_PATH: &str = "/etc/libvirt/hooks/network"; 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;

View 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,
}))
}

View File

@ -6,6 +6,7 @@ use std::error::Error;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::io::ErrorKind; use std::io::ErrorKind;
pub mod api_tokens_controller;
pub mod auth_controller; pub mod auth_controller;
pub mod iso_controller; pub mod iso_controller;
pub mod network_controller; pub mod network_controller;

View File

@ -51,6 +51,8 @@ struct ServerConstraints {
nwfilter_comment_size: LenConstraints, nwfilter_comment_size: LenConstraints,
nwfilter_priority: SLenConstraints, nwfilter_priority: SLenConstraints,
nwfilter_selectors_count: LenConstraints, nwfilter_selectors_count: LenConstraints,
api_token_name_size: LenConstraints,
api_token_description_size: LenConstraints,
} }
pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { 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, max: 1000,
}, },
nwfilter_selectors_count: LenConstraints { min: 0, max: 1 }, 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,
},
}, },
}) })
} }

View File

@ -1,4 +1,5 @@
pub mod actors; pub mod actors;
pub mod api_tokens;
pub mod app_config; pub mod app_config;
pub mod constants; pub mod constants;
pub mod controllers; pub mod controllers;

View File

@ -22,8 +22,8 @@ use virtweb_backend::constants::{
MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME, MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME,
}; };
use virtweb_backend::controllers::{ use virtweb_backend::controllers::{
auth_controller, iso_controller, network_controller, nwfilter_controller, server_controller, api_tokens_controller, auth_controller, iso_controller, network_controller,
static_controller, vm_controller, nwfilter_controller, server_controller, static_controller, vm_controller,
}; };
use virtweb_backend::libvirt_client::LibVirtClient; use virtweb_backend::libvirt_client::LibVirtClient;
use virtweb_backend::middlewares::auth_middleware::AuthChecker; 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().disks_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().nat_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().definitions_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap();
let conn = Data::new(LibVirtClient( let conn = Data::new(LibVirtClient(
LibVirtActor::connect() LibVirtActor::connect()
@ -276,6 +277,27 @@ async fn main() -> std::io::Result<()> {
"/api/nwfilter/{uid}", "/api/nwfilter/{uid}",
web::delete().to(nwfilter_controller::delete), 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 // Static assets
.route("/", web::get().to(static_controller::root_index)) .route("/", web::get().to(static_controller::root_index))
.route( .route(

View 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)
}
}

View File

@ -1,5 +1,6 @@
pub mod disks_utils; pub mod disks_utils;
pub mod files_utils; pub mod files_utils;
pub mod jwt_utils;
pub mod net_utils; pub mod net_utils;
pub mod rand_utils; pub mod rand_utils;
pub mod time_utils; pub mod time_utils;