From 544513d118f30b8b004c379e5760d1edd284a396 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Wed, 19 Mar 2025 18:57:38 +0100 Subject: [PATCH] Add tokens routes --- moneymgr_backend/Cargo.lock | 28 +++++ moneymgr_backend/Cargo.toml | 4 +- .../up.sql | 11 +- moneymgr_backend/src/constants.rs | 5 + .../src/controllers/auth_controller.rs | 6 +- moneymgr_backend/src/controllers/mod.rs | 1 + .../src/controllers/server_controller.rs | 34 +++++- .../src/controllers/tokens_controller.rs | 87 ++++++++++++++ .../src/extractors/auth_extractor.rs | 12 +- moneymgr_backend/src/main.rs | 12 +- moneymgr_backend/src/models/mod.rs | 1 + moneymgr_backend/src/models/tokens.rs | 72 ++++++++++++ moneymgr_backend/src/routines.rs | 3 +- moneymgr_backend/src/schema.rs | 9 +- moneymgr_backend/src/services/mod.rs | 1 + .../src/services/tokens_service.rs | 108 ++++++++++++++++++ 16 files changed, 376 insertions(+), 18 deletions(-) create mode 100644 moneymgr_backend/src/controllers/tokens_controller.rs create mode 100644 moneymgr_backend/src/models/tokens.rs create mode 100644 moneymgr_backend/src/services/tokens_service.rs diff --git a/moneymgr_backend/Cargo.lock b/moneymgr_backend/Cargo.lock index 22efdde..5acd0f1 100644 --- a/moneymgr_backend/Cargo.lock +++ b/moneymgr_backend/Cargo.lock @@ -1598,6 +1598,9 @@ name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] [[package]] name = "is_terminal_polyfill" @@ -1660,6 +1663,29 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" +[[package]] +name = "lazy-regex" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60c7310b93682b36b98fa7ea4de998d3463ccbebd94d935d6b48ba5b6ffa7126" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba01db5ef81e17eb10a5e0f2109d1b3a3e29bac3070fdbd7d156bf7dbd206a1" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1826,6 +1852,8 @@ dependencies = [ "diesel_migrations", "env_logger", "futures-util", + "ipnet", + "lazy-regex", "lazy_static", "light-openid", "log", diff --git a/moneymgr_backend/Cargo.toml b/moneymgr_backend/Cargo.toml index 8b4cd2c..82ceaa9 100644 --- a/moneymgr_backend/Cargo.toml +++ b/moneymgr_backend/Cargo.toml @@ -23,4 +23,6 @@ tokio = "1.44.1" futures-util = "0.3.31" serde_json = "1.0.140" light-openid = "1.0.4" -rand = "0.9.0" \ No newline at end of file +rand = "0.9.0" +ipnet = { version = "2.11.0", features = ["serde"] } +lazy-regex = "3.4.1" \ No newline at end of file diff --git a/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/up.sql b/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/up.sql index e082f1e..04107cd 100644 --- a/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/up.sql +++ b/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/up.sql @@ -10,20 +10,19 @@ CREATE TABLE users CREATE TABLE token ( id SERIAL PRIMARY KEY, - label VARCHAR(150) NOT NULL, + name VARCHAR(150) NOT NULL, time_create BIGINT NOT NULL, - time_update BIGINT NOT NULL, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, token_value VARCHAR(150) NOT NULL, time_used BIGINT NOT NULL, - max_inactivity INTEGER, - ip_restriction VARCHAR(50), + max_inactivity INTEGER NOT NULL, + ip_net VARCHAR(50), read_only BOOLEAN NOT NULL DEFAULT true, right_account BOOLEAN NOT NULL DEFAULT false, right_movement BOOLEAN NOT NULL DEFAULT false, right_inbox BOOLEAN NOT NULL DEFAULT false, right_attachment BOOLEAN NOT NULL DEFAULT false, - right_user BOOLEAN NOT NULL DEFAULT false + right_auth BOOLEAN NOT NULL DEFAULT false ); CREATE TABLE attachment @@ -33,7 +32,7 @@ CREATE TABLE attachment mime_type VARCHAR(150) NOT NULL, sha512 VARCHAR(130) NOT NULL, file_size INTEGER NOT NULL, - user_id INTEGER NOT NULL REFERENCES users ON DELETE RESTRICT + user_id INTEGER NOT NULL REFERENCES users ON DELETE SET NULL ); CREATE TABLE account diff --git a/moneymgr_backend/src/constants.rs b/moneymgr_backend/src/constants.rs index 96c387b..2e52df7 100644 --- a/moneymgr_backend/src/constants.rs +++ b/moneymgr_backend/src/constants.rs @@ -1,3 +1,8 @@ +//! # Project constants + +/// Length of generated tokens +pub const TOKENS_LEN: usize = 50; + /// Header used to authenticate API requests made using a token pub const API_TOKEN_HEADER: &str = "X-Auth-Token"; diff --git a/moneymgr_backend/src/controllers/auth_controller.rs b/moneymgr_backend/src/controllers/auth_controller.rs index 102e8b7..a4ebc0d 100644 --- a/moneymgr_backend/src/controllers/auth_controller.rs +++ b/moneymgr_backend/src/controllers/auth_controller.rs @@ -2,7 +2,7 @@ use crate::app_config::AppConfig; use crate::controllers::{HttpFailure, HttpResult}; use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod}; use crate::extractors::money_session::MoneySession; -use crate::services::users_service; +use crate::services::{tokens_service, users_service}; use actix_remote_ip::RemoteIP; use actix_web::{HttpResponse, web}; use light_openid::primitives::OpenIDConfig; @@ -118,6 +118,10 @@ pub async fn sign_out(auth: AuthExtractor, session: MoneySession) -> HttpResult session.unset_current_user()?; } + AuthenticatedMethod::Token(token) => { + tokens_service::delete(token.user_id(), token.id()).await?; + } + AuthenticatedMethod::Dev => { // Nothing to be done, user is always authenticated } diff --git a/moneymgr_backend/src/controllers/mod.rs b/moneymgr_backend/src/controllers/mod.rs index dbcb9f1..d358e30 100644 --- a/moneymgr_backend/src/controllers/mod.rs +++ b/moneymgr_backend/src/controllers/mod.rs @@ -4,6 +4,7 @@ use std::error::Error; pub mod auth_controller; pub mod server_controller; +pub mod tokens_controller; #[derive(thiserror::Error, Debug)] pub enum HttpFailure { diff --git a/moneymgr_backend/src/controllers/server_controller.rs b/moneymgr_backend/src/controllers/server_controller.rs index 0de1830..22683ee 100644 --- a/moneymgr_backend/src/controllers/server_controller.rs +++ b/moneymgr_backend/src/controllers/server_controller.rs @@ -8,15 +8,45 @@ pub async fn robots_txt() -> HttpResponse { .body("User-agent: *\nDisallow: /\n") } +#[derive(serde::Serialize)] +pub struct LenConstraints { + min: usize, + max: usize, +} + +impl LenConstraints { + pub fn new(min: usize, max: usize) -> Self { + Self { min, max } + } + pub fn not_empty(max: usize) -> Self { + Self { min: 1, max } + } + pub fn max_only(max: usize) -> Self { + Self { min: 0, max } + } + + pub fn check_str(&self, s: &str) -> bool { + s.len() >= self.min && s.len() <= self.max + } + + pub fn check_u32(&self, v: u32) -> bool { + v >= self.min as u32 && v <= self.max as u32 + } +} + #[derive(serde::Serialize)] pub struct ServerConstraints { - // TODO + pub token_name: LenConstraints, + pub token_ip_net: LenConstraints, + pub token_max_inactivity: LenConstraints, } impl Default for ServerConstraints { fn default() -> Self { Self { - // TODO + token_name: LenConstraints::new(5, 255), + token_ip_net: LenConstraints::max_only(44), + token_max_inactivity: LenConstraints::new(3600, 3600 * 24 * 365), } } } diff --git a/moneymgr_backend/src/controllers/tokens_controller.rs b/moneymgr_backend/src/controllers/tokens_controller.rs new file mode 100644 index 0000000..52d8ab5 --- /dev/null +++ b/moneymgr_backend/src/controllers/tokens_controller.rs @@ -0,0 +1,87 @@ +use crate::controllers::HttpResult; +use crate::controllers::server_controller::ServerConstraints; +use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod}; +use crate::models::tokens::{Token, TokenID}; +use crate::services::tokens_service; +use crate::services::tokens_service::NewTokenInfo; +use actix_web::{HttpResponse, web}; + +#[derive(serde::Deserialize)] +pub struct CreateTokenBody { + name: String, + ip_net: Option, + max_inactivity: u32, + read_only: bool, + right_account: bool, + right_movement: bool, + right_inbox: bool, + right_attachment: bool, + right_auth: bool, +} + +#[derive(serde::Serialize)] +pub struct CreateTokenResult { + #[serde(flatten)] + info: Token, + token: String, +} + +/// Create a new token +pub async fn create(auth: AuthExtractor, req: web::Json) -> HttpResult { + if matches!(auth.method, AuthenticatedMethod::Token(_)) { + return Ok(HttpResponse::Forbidden() + .json("It is not allowed to create a token using another token!")); + } + + let constraints = ServerConstraints::default(); + + if !lazy_regex::regex!("^[a-zA-Z0-9 :-]+$").is_match(&req.name) { + return Ok(HttpResponse::BadRequest().json("Token name contains invalid characters!")); + } + + if !constraints.token_name.check_str(&req.name) { + return Ok(HttpResponse::BadRequest().json("Invalid token name length!")); + } + + if !constraints + .token_max_inactivity + .check_u32(req.max_inactivity) + { + return Ok(HttpResponse::BadRequest().json("Invalid token max inactivity!")); + } + + let token = tokens_service::create(NewTokenInfo { + user_id: auth.user_id(), + max_inactivity: req.max_inactivity, + ip_net: req.ip_net, + name: req.name.clone(), + read_only: req.read_only, + right_account: req.right_account, + right_movement: req.right_movement, + right_inbox: req.right_inbox, + right_attachment: req.right_attachment, + right_auth: req.right_auth, + }) + .await?; + + Ok(HttpResponse::Created().json(CreateTokenResult { + token: token.token_value.to_string(), + info: token, + })) +} + +/// Get the list of tokens of the user +pub async fn get_list(auth: AuthExtractor) -> HttpResult { + Ok(HttpResponse::Ok().json(tokens_service::get_list_user(auth.user_id()).await?)) +} + +#[derive(serde::Deserialize)] +pub struct TokenIDInPath { + id: TokenID, +} + +/// Delete an API access token +pub async fn delete(auth: AuthExtractor, path: web::Path) -> HttpResult { + tokens_service::delete(auth.user_id(), path.id).await?; + Ok(HttpResponse::Accepted().finish()) +} diff --git a/moneymgr_backend/src/extractors/auth_extractor.rs b/moneymgr_backend/src/extractors/auth_extractor.rs index 3e3f8ab..d37a6e6 100644 --- a/moneymgr_backend/src/extractors/auth_extractor.rs +++ b/moneymgr_backend/src/extractors/auth_extractor.rs @@ -1,6 +1,7 @@ use crate::app_config::AppConfig; use crate::extractors::money_session::MoneySession; -use crate::models::users::User; +use crate::models::tokens::Token; +use crate::models::users::{User, UserID}; use crate::services::users_service; use actix_web::dev::Payload; use actix_web::error::ErrorPreconditionFailed; @@ -12,6 +13,8 @@ pub enum AuthenticatedMethod { Cookie, /// User is authenticated through command line, for debugging purposes only Dev, + // TODO : token implementation + Token(Token), } /// Authentication extractor. Extract authentication information from request @@ -20,6 +23,13 @@ pub struct AuthExtractor { pub user: User, } +impl AuthExtractor { + /// Get current user ID + pub fn user_id(&self) -> UserID { + self.user.id() + } +} + impl FromRequest for AuthExtractor { type Error = Error; type Future = futures_util::future::LocalBoxFuture<'static, Result>; diff --git a/moneymgr_backend/src/main.rs b/moneymgr_backend/src/main.rs index 7dde136..ac7a5a4 100644 --- a/moneymgr_backend/src/main.rs +++ b/moneymgr_backend/src/main.rs @@ -9,7 +9,7 @@ use actix_web::middleware::Logger; use actix_web::{App, HttpServer, web}; use moneymgr_backend::app_config::AppConfig; use moneymgr_backend::connections::{db_connection, s3_connection}; -use moneymgr_backend::controllers::{auth_controller, server_controller}; +use moneymgr_backend::controllers::*; use moneymgr_backend::services::users_service; use moneymgr_backend::{constants, routines}; @@ -90,6 +90,16 @@ async fn main() -> std::io::Result<()> { "/api/auth/sign_out", web::get().to(auth_controller::sign_out), ) + // Tokens controller + .route("/api/tokens", web::post().to(tokens_controller::create)) + .route( + "/api/tokens/list", + web::get().to(tokens_controller::get_list), + ) + .route( + "/api/tokens/{id}", + web::delete().to(tokens_controller::delete), + ) }) .bind(AppConfig::get().listen_address.as_str())? .run() diff --git a/moneymgr_backend/src/models/mod.rs b/moneymgr_backend/src/models/mod.rs index 913bd46..bf3b714 100644 --- a/moneymgr_backend/src/models/mod.rs +++ b/moneymgr_backend/src/models/mod.rs @@ -1 +1,2 @@ +pub mod tokens; pub mod users; diff --git a/moneymgr_backend/src/models/tokens.rs b/moneymgr_backend/src/models/tokens.rs new file mode 100644 index 0000000..866d8b0 --- /dev/null +++ b/moneymgr_backend/src/models/tokens.rs @@ -0,0 +1,72 @@ +use crate::models::users::UserID; +use crate::schema::*; +use crate::utils::time_utils::time; +use diesel::prelude::*; +use std::cmp::min; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct TokenID(pub i32); + +#[derive(Default, Queryable, Debug, Clone, serde::Serialize)] +pub struct Token { + id: i32, + pub name: String, + pub time_create: i64, + user_id: i32, + #[serde(skip_serializing)] + pub token_value: String, + pub time_used: i64, + pub max_inactivity: i32, + ip_net: Option, + pub read_only: bool, + pub right_account: bool, + pub right_movement: bool, + pub right_inbox: bool, + pub right_attachment: bool, + pub right_auth: bool, +} + +impl Token { + pub fn id(&self) -> TokenID { + TokenID(self.id) + } + + pub fn user_id(&self) -> UserID { + UserID(self.user_id) + } + + pub fn ip_net(&self) -> Option { + self.ip_net + .as_ref() + .map(|i| ipnet::IpNet::from_str(i).unwrap()) + } + + pub fn shall_update_time_used(&self) -> bool { + let refresh_interval = min(600, self.max_inactivity / 10); + + (self.time_used as u64) < time() - refresh_interval as u64 + } + + pub fn is_expired(&self) -> bool { + (self.time_used + self.max_inactivity as i64) < time() as i64 + } +} + +#[derive(Insertable)] +#[diesel(table_name = token)] +pub struct NewToken<'a> { + pub name: &'a str, + pub user_id: i32, + pub time_create: i64, + pub time_used: i64, + pub max_inactivity: i32, + pub ip_net: Option<&'a str>, + pub token_value: &'a str, + pub read_only: bool, + pub right_account: bool, + pub right_movement: bool, + pub right_inbox: bool, + pub right_attachment: bool, + pub right_auth: bool, +} diff --git a/moneymgr_backend/src/routines.rs b/moneymgr_backend/src/routines.rs index a3fe7ba..0f0fd8a 100644 --- a/moneymgr_backend/src/routines.rs +++ b/moneymgr_backend/src/routines.rs @@ -19,6 +19,7 @@ pub async fn main_routine() { } async fn exec_routine() -> anyhow::Result<()> { - // TODO + // TODO : remove orphan attachment + // TODO : remove outdated tokens Ok(()) } diff --git a/moneymgr_backend/src/schema.rs b/moneymgr_backend/src/schema.rs index 3698738..f8cb76c 100644 --- a/moneymgr_backend/src/schema.rs +++ b/moneymgr_backend/src/schema.rs @@ -55,22 +55,21 @@ diesel::table! { token (id) { id -> Int4, #[max_length = 150] - label -> Varchar, + name -> Varchar, time_create -> Int8, - time_update -> Int8, user_id -> Int4, #[max_length = 150] token_value -> Varchar, time_used -> Int8, - max_inactivity -> Nullable, + max_inactivity -> Int4, #[max_length = 50] - ip_restriction -> Nullable, + ip_net -> Nullable, read_only -> Bool, right_account -> Bool, right_movement -> Bool, right_inbox -> Bool, right_attachment -> Bool, - right_user -> Bool, + right_auth -> Bool, } } diff --git a/moneymgr_backend/src/services/mod.rs b/moneymgr_backend/src/services/mod.rs index 433996f..ff9dfc0 100644 --- a/moneymgr_backend/src/services/mod.rs +++ b/moneymgr_backend/src/services/mod.rs @@ -1 +1,2 @@ +pub mod tokens_service; pub mod users_service; diff --git a/moneymgr_backend/src/services/tokens_service.rs b/moneymgr_backend/src/services/tokens_service.rs new file mode 100644 index 0000000..411fd52 --- /dev/null +++ b/moneymgr_backend/src/services/tokens_service.rs @@ -0,0 +1,108 @@ +use diesel::prelude::*; + +use crate::connections::db_connection::db; +use crate::constants; +use crate::models::tokens::{NewToken, Token, TokenID}; +use crate::models::users::UserID; +use crate::schema::token; +use crate::utils::rand_utils::rand_string; +use crate::utils::time_utils::time; + +pub struct NewTokenInfo { + pub user_id: UserID, + pub name: String, + pub max_inactivity: u32, + pub ip_net: Option, + pub read_only: bool, + pub right_account: bool, + pub right_movement: bool, + pub right_inbox: bool, + pub right_attachment: bool, + pub right_auth: bool, +} + +/// Create a new token +pub async fn create(new_token: NewTokenInfo) -> anyhow::Result { + let ip_net = new_token.ip_net.map(|i| i.to_string()); + let token = rand_string(constants::TOKENS_LEN); + let t = NewToken { + name: &new_token.name, + user_id: new_token.user_id.0, + time_create: time() as i64, + time_used: time() as i64, + max_inactivity: new_token.max_inactivity as i32, + read_only: new_token.read_only, + ip_net: ip_net.as_deref(), + token_value: &token, + right_auth: new_token.right_auth, + right_account: new_token.right_account, + right_movement: new_token.right_movement, + right_inbox: new_token.right_inbox, + right_attachment: new_token.right_attachment, + }; + + let res = diesel::insert_into(token::table) + .values(&t) + .get_result(&mut db()?)?; + + Ok(res) +} + +/// Get a single token by its id +pub async fn get_by_id(token_id: TokenID) -> anyhow::Result { + Ok(token::table + .filter(token::dsl::id.eq(token_id.0)) + .get_result(&mut db()?)?) +} + +/// Get a single token by its name +pub fn get_by_name(name: &str) -> anyhow::Result { + Ok(token::table + .filter(token::dsl::name.eq(name)) + .get_result(&mut db()?)?) +} + +/// Get a single token by its value +pub async fn get_by_value(value: &str) -> anyhow::Result { + Ok(token::table + .filter(token::dsl::token_value.eq(value)) + .get_result(&mut db()?)?) +} + +/// Get the token of a user +pub async fn get_list_user(id: UserID) -> anyhow::Result> { + Ok(token::table + .filter(token::dsl::user_id.eq(id.0)) + .get_results(&mut db()?)?) +} + +/// Update last used value of a token +pub async fn update_time_used(token: &Token) -> anyhow::Result<()> { + diesel::update(token::dsl::token.filter(token::dsl::id.eq(token.id().0))) + .set(token::dsl::time_used.eq(time() as i64)) + .execute(&mut db()?)?; + Ok(()) +} + +/// Delete the token of a user +pub async fn delete(user_id: UserID, token_id: TokenID) -> anyhow::Result<()> { + diesel::delete( + token::dsl::token.filter( + token::dsl::id + .eq(token_id.0) + .and(token::dsl::user_id.eq(user_id.0)), + ), + ) + .execute(&mut db()?)?; + Ok(()) +} + +/// Remove outdated token +pub async fn cleanup() -> anyhow::Result<()> { + let query = format!( + "DELETE from token where last_used + max_inactivity < {};", + time() + ); + diesel::sql_query(query).execute(&mut db()?)?; + Ok(()) +}