Create & list tokens

This commit is contained in:
2025-11-11 21:19:54 +01:00
parent b10ec9ce92
commit 8fdf1d57eb
8 changed files with 202 additions and 22 deletions

View File

@@ -2530,6 +2530,29 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
[[package]]
name = "lazy-regex"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "191898e17ddee19e60bccb3945aa02339e81edd4a8c50e21fd4d48cdecda7b29"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c35dc8b0da83d1a9507e12122c80dea71a9c7c613014347392483a83ea593e04"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -3012,6 +3035,7 @@ dependencies = [
"hex",
"ipnet",
"jwt-simple",
"lazy-regex",
"lazy_static",
"light-openid",
"log",

View File

@@ -32,3 +32,4 @@ matrix-sdk = "0.14.0"
url = "2.5.7"
ractor = "0.15.9"
serde_json = "1.0.145"
lazy-regex = "3.4.2"

View File

@@ -4,6 +4,9 @@ pub const API_AUTH_HEADER: &str = "x-client-auth";
/// Max token validity, in seconds
pub const API_TOKEN_JWT_MAX_DURATION: u64 = 15 * 60;
/// Length of generated tokens
pub const TOKENS_LEN: usize = 50;
/// Session-specific constants
pub mod sessions {
/// OpenID auth session state key

View File

@@ -5,6 +5,7 @@ use std::error::Error;
pub mod auth_controller;
pub mod matrix_link_controller;
pub mod server_controller;
pub mod tokens_controller;
#[derive(thiserror::Error, Debug)]
pub enum HttpFailure {

View File

@@ -0,0 +1,36 @@
use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::users::{APIToken, BaseAPIToken};
use actix_web::HttpResponse;
/// Create a new token
pub async fn create(auth: AuthExtractor) -> HttpResult {
if matches!(auth.method, AuthenticatedMethod::Token(_)) {
return Ok(HttpResponse::Forbidden()
.json("It is not allowed to create a token using another token!"));
}
let base = auth.decode_json_body::<BaseAPIToken>()?;
if let Some(err) = base.check() {
return Ok(HttpResponse::BadRequest().json(err));
}
let token = APIToken::create(&auth.as_ref().email, base).await?;
Ok(HttpResponse::Ok().json(token))
}
/// Get the list of tokens of current user
pub async fn get_list(auth: AuthExtractor) -> HttpResult {
Ok(HttpResponse::Ok().json(
APIToken::list_user(&auth.as_ref().email)
.await?
.into_iter()
.map(|mut t| {
t.secret = String::new();
t
})
.collect::<Vec<_>>(),
))
}

View File

@@ -34,6 +34,12 @@ pub struct AuthExtractor {
pub payload: Option<Vec<u8>>,
}
impl AsRef<User> for AuthExtractor {
fn as_ref(&self) -> &User {
&self.user
}
}
impl AuthExtractor {
pub fn decode_json_body<E: DeserializeOwned + Send>(&self) -> anyhow::Result<E> {
let payload = self
@@ -156,8 +162,9 @@ impl AuthExtractor {
}
// Check IP restriction
if let Some(net) = token.network
&& !net.contains(&remote_ip)
if let Some(nets) = &token.base.networks
&& !nets.is_empty()
&& !nets.iter().any(|n| n.contains(&remote_ip))
{
log::error!(
"Trying to use token {:?} from unauthorized IP address: {remote_ip:?}",
@@ -169,7 +176,7 @@ impl AuthExtractor {
}
// Check for write access
if token.read_only && !req.method().is_safe() {
if token.base.read_only && !req.method().is_safe() {
return Err(actix_web::error::ErrorBadRequest(
"Read only token cannot perform write operations!",
));

View File

@@ -9,7 +9,9 @@ use actix_web::{App, HttpServer, web};
use matrixgw_backend::app_config::AppConfig;
use matrixgw_backend::broadcast_messages::BroadcastMessage;
use matrixgw_backend::constants;
use matrixgw_backend::controllers::{auth_controller, matrix_link_controller, server_controller};
use matrixgw_backend::controllers::{
auth_controller, matrix_link_controller, server_controller, tokens_controller,
};
use matrixgw_backend::matrix_connection::matrix_manager::MatrixManagerActor;
use matrixgw_backend::users::User;
use ractor::Actor;
@@ -110,6 +112,9 @@ async fn main() -> std::io::Result<()> {
"/api/matrix_link/set_recovery_key",
web::post().to(matrix_link_controller::set_recovery_key),
)
// API Tokens controller
.route("/api/token", web::post().to(tokens_controller::create))
.route("/api/tokens", web::get().to(tokens_controller::get_list))
})
.workers(4)
.bind(&AppConfig::get().listen_address)?

View File

@@ -1,6 +1,10 @@
use crate::app_config::AppConfig;
use crate::constants;
use crate::controllers::server_controller::ServerConstraints;
use crate::matrix_connection::matrix_client::EncryptionRecoveryState;
use crate::utils::rand_utils::rand_string;
use crate::utils::time_utils::time_secs;
use anyhow::Context;
use jwt_simple::reexports::serde_json;
use std::cmp::min;
use std::str::FromStr;
@@ -14,6 +18,8 @@ enum MatrixGWUserError {
DecodeUserMetadata(serde_json::Error),
#[error("Failed to save user metadata: {0}")]
SaveUserMetadata(std::io::Error),
#[error("Failed to create API token directory: {0}")]
CreateApiTokensDirectory(std::io::Error),
#[error("Failed to delete API token: {0}")]
DeleteToken(std::io::Error),
#[error("Failed to load API token: {0}")]
@@ -101,17 +107,63 @@ impl User {
}
}
/// Single API client information
/// Base API token information
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct APIToken {
/// Token unique ID
pub id: APITokenID,
/// Client description
pub description: String,
pub struct BaseAPIToken {
/// Token name
pub name: String,
/// Restricted API network for token
pub network: Option<ipnet::IpNet>,
pub networks: Option<Vec<ipnet::IpNet>>,
/// Read only access
pub read_only: bool,
/// Token max inactivity
pub max_inactivity: u32,
/// Token expiration
pub expiration: Option<u64>,
}
impl BaseAPIToken {
/// Check API token information validity
pub fn check(&self) -> Option<&'static str> {
let constraints = ServerConstraints::default();
if !lazy_regex::regex!("^[a-zA-Z0-9 :-]+$").is_match(&self.name) {
return Some("Token name contains invalid characters!");
}
if !constraints.token_name.check_str(&self.name) {
return Some("Invalid token name length!");
}
if !constraints
.token_max_inactivity
.check_u32(self.max_inactivity)
{
return Some("Invalid token max inactivity!");
}
if let Some(expiration) = self.expiration
&& expiration <= time_secs()
{
return Some("Given expiration time is in the past!");
}
None
}
}
/// Single API token information
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct APIToken {
#[serde(flatten)]
pub base: BaseAPIToken,
/// Token unique ID
pub id: APITokenID,
/// Client secret
pub secret: String,
@@ -121,15 +173,58 @@ pub struct APIToken {
/// Client last usage time
pub last_used: u64,
/// Read only access
pub read_only: bool,
/// Token max inactivity
pub max_inactivity: u64,
}
impl APIToken {
/// Get the list of tokens of a user
pub async fn list_user(email: &UserEmail) -> anyhow::Result<Vec<Self>> {
let tokens_dir = AppConfig::get().user_api_token_directory(email);
if !tokens_dir.exists() {
return Ok(vec![]);
}
let mut list = vec![];
for u in std::fs::read_dir(&tokens_dir)? {
let entry = u?;
list.push(
Self::load(
email,
&APITokenID::from_str(
entry
.file_name()
.to_str()
.context("Cannot decode API Token ID as string!")?,
)?,
)
.await?,
);
}
Ok(list)
}
/// Create a new token
pub async fn create(email: &UserEmail, base: BaseAPIToken) -> anyhow::Result<Self> {
let tokens_dir = AppConfig::get().user_api_token_directory(email);
if !tokens_dir.exists() {
std::fs::create_dir_all(tokens_dir)
.map_err(MatrixGWUserError::CreateApiTokensDirectory)?;
}
let token = APIToken {
base,
id: Default::default(),
secret: rand_string(constants::TOKENS_LEN),
created: time_secs(),
last_used: time_secs(),
};
token.write(email).await?;
Ok(token)
}
/// Get a token information
pub async fn load(email: &UserEmail, id: &APITokenID) -> anyhow::Result<Self> {
let token_file = AppConfig::get().user_api_token_metadata_file(email, id);
@@ -158,13 +253,21 @@ impl APIToken {
}
pub fn shall_update_time_used(&self) -> bool {
let refresh_interval = min(600, self.max_inactivity / 10);
let refresh_interval = min(600, self.base.max_inactivity / 10);
(self.last_used) < time_secs() - refresh_interval
(self.last_used) < time_secs() - refresh_interval as u64
}
pub fn is_expired(&self) -> bool {
(self.last_used + self.max_inactivity) < time_secs()
// Check for hard coded expiration
if let Some(exp_time) = self.base.expiration
&& exp_time < time_secs()
{
return true;
}
// Control max token inactivity
(self.last_used + self.base.max_inactivity as u64) < time_secs()
}
}