Add API tokens support (#9)
All checks were successful
continuous-integration/drone/push Build is passing

Make it possible to create token authorized to query predetermined set of routes.

Reviewed-on: #9
Co-authored-by: Pierre HUBERT <pierre.git@communiquons.org>
Co-committed-by: Pierre HUBERT <pierre.git@communiquons.org>
This commit is contained in:
2024-04-23 17:04:43 +00:00
committed by Pierre Hubert
parent 149e3f4d72
commit c7de64cc02
33 changed files with 2686 additions and 60 deletions

View File

@ -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<Self> {
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<TokenRight>);
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::<Vec<_>>();
'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<JWTPublicKey>,
pub rights: TokenRights,
pub last_used: u64,
pub ip_restriction: Option<ipnetwork::IpNetwork>,
pub max_inactivity: Option<u64>,
}
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<Self> {
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<Self, Self::Err> {
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<ipnetwork::IpNetwork>,
pub max_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!");
}
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<Vec<Token>> {
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> {
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"));
}
}

View File

@ -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)]

View File

@ -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;

View File

@ -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<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,
}))
}
/// Get the list of API tokens
pub async fn list() -> HttpResult {
let list = api_tokens::full_list()
.await?
.into_iter()
.map(RestToken::new)
.collect::<Vec<_>>();
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<TokenIDInPath>) -> 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<TokenIDInPath>,
body: web::Json<UpdateTokenBody>,
) -> 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<TokenIDInPath>) -> HttpResult {
api_tokens::delete(path.uid).await?;
Ok(HttpResponse::Accepted().finish())
}

View File

@ -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;

View File

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

View File

@ -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<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
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::<TokenClaims>(&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 })
})
}
}

View File

@ -1,2 +1,3 @@
pub mod api_auth_extractor;
pub mod auth_extractor;
pub mod local_auth_extractor;

View File

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

View File

@ -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(

View File

@ -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/")
{