Add tokens routes

This commit is contained in:
Pierre HUBERT 2025-03-19 18:57:38 +01:00
parent 3081757536
commit 544513d118
16 changed files with 376 additions and 18 deletions

View File

@ -1598,6 +1598,9 @@ name = "ipnet"
version = "2.11.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
@ -1660,6 +1663,29 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" 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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -1826,6 +1852,8 @@ dependencies = [
"diesel_migrations", "diesel_migrations",
"env_logger", "env_logger",
"futures-util", "futures-util",
"ipnet",
"lazy-regex",
"lazy_static", "lazy_static",
"light-openid", "light-openid",
"log", "log",

View File

@ -24,3 +24,5 @@ futures-util = "0.3.31"
serde_json = "1.0.140" serde_json = "1.0.140"
light-openid = "1.0.4" light-openid = "1.0.4"
rand = "0.9.0" rand = "0.9.0"
ipnet = { version = "2.11.0", features = ["serde"] }
lazy-regex = "3.4.1"

View File

@ -10,20 +10,19 @@ CREATE TABLE users
CREATE TABLE token CREATE TABLE token
( (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
label VARCHAR(150) NOT NULL, name VARCHAR(150) NOT NULL,
time_create BIGINT NOT NULL, time_create BIGINT NOT NULL,
time_update BIGINT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
token_value VARCHAR(150) NOT NULL, token_value VARCHAR(150) NOT NULL,
time_used BIGINT NOT NULL, time_used BIGINT NOT NULL,
max_inactivity INTEGER, max_inactivity INTEGER NOT NULL,
ip_restriction VARCHAR(50), ip_net VARCHAR(50),
read_only BOOLEAN NOT NULL DEFAULT true, read_only BOOLEAN NOT NULL DEFAULT true,
right_account BOOLEAN NOT NULL DEFAULT false, right_account BOOLEAN NOT NULL DEFAULT false,
right_movement BOOLEAN NOT NULL DEFAULT false, right_movement BOOLEAN NOT NULL DEFAULT false,
right_inbox BOOLEAN NOT NULL DEFAULT false, right_inbox BOOLEAN NOT NULL DEFAULT false,
right_attachment 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 CREATE TABLE attachment
@ -33,7 +32,7 @@ CREATE TABLE attachment
mime_type VARCHAR(150) NOT NULL, mime_type VARCHAR(150) NOT NULL,
sha512 VARCHAR(130) NOT NULL, sha512 VARCHAR(130) NOT NULL,
file_size INTEGER 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 CREATE TABLE account

View File

@ -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 /// Header used to authenticate API requests made using a token
pub const API_TOKEN_HEADER: &str = "X-Auth-Token"; pub const API_TOKEN_HEADER: &str = "X-Auth-Token";

View File

@ -2,7 +2,7 @@ use crate::app_config::AppConfig;
use crate::controllers::{HttpFailure, HttpResult}; use crate::controllers::{HttpFailure, HttpResult};
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod}; use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::extractors::money_session::MoneySession; 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_remote_ip::RemoteIP;
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use light_openid::primitives::OpenIDConfig; use light_openid::primitives::OpenIDConfig;
@ -118,6 +118,10 @@ pub async fn sign_out(auth: AuthExtractor, session: MoneySession) -> HttpResult
session.unset_current_user()?; session.unset_current_user()?;
} }
AuthenticatedMethod::Token(token) => {
tokens_service::delete(token.user_id(), token.id()).await?;
}
AuthenticatedMethod::Dev => { AuthenticatedMethod::Dev => {
// Nothing to be done, user is always authenticated // Nothing to be done, user is always authenticated
} }

View File

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

View File

@ -8,15 +8,45 @@ pub async fn robots_txt() -> HttpResponse {
.body("User-agent: *\nDisallow: /\n") .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)] #[derive(serde::Serialize)]
pub struct ServerConstraints { pub struct ServerConstraints {
// TODO pub token_name: LenConstraints,
pub token_ip_net: LenConstraints,
pub token_max_inactivity: LenConstraints,
} }
impl Default for ServerConstraints { impl Default for ServerConstraints {
fn default() -> Self { fn default() -> Self {
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),
} }
} }
} }

View File

@ -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<ipnet::IpNet>,
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<CreateTokenBody>) -> 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<TokenIDInPath>) -> HttpResult {
tokens_service::delete(auth.user_id(), path.id).await?;
Ok(HttpResponse::Accepted().finish())
}

View File

@ -1,6 +1,7 @@
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
use crate::extractors::money_session::MoneySession; 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 crate::services::users_service;
use actix_web::dev::Payload; use actix_web::dev::Payload;
use actix_web::error::ErrorPreconditionFailed; use actix_web::error::ErrorPreconditionFailed;
@ -12,6 +13,8 @@ pub enum AuthenticatedMethod {
Cookie, Cookie,
/// User is authenticated through command line, for debugging purposes only /// User is authenticated through command line, for debugging purposes only
Dev, Dev,
// TODO : token implementation
Token(Token),
} }
/// Authentication extractor. Extract authentication information from request /// Authentication extractor. Extract authentication information from request
@ -20,6 +23,13 @@ pub struct AuthExtractor {
pub user: User, pub user: User,
} }
impl AuthExtractor {
/// Get current user ID
pub fn user_id(&self) -> UserID {
self.user.id()
}
}
impl FromRequest for AuthExtractor { impl FromRequest for AuthExtractor {
type Error = Error; type Error = Error;
type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>; type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>;

View File

@ -9,7 +9,7 @@ use actix_web::middleware::Logger;
use actix_web::{App, HttpServer, web}; use actix_web::{App, HttpServer, web};
use moneymgr_backend::app_config::AppConfig; use moneymgr_backend::app_config::AppConfig;
use moneymgr_backend::connections::{db_connection, s3_connection}; 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::services::users_service;
use moneymgr_backend::{constants, routines}; use moneymgr_backend::{constants, routines};
@ -90,6 +90,16 @@ async fn main() -> std::io::Result<()> {
"/api/auth/sign_out", "/api/auth/sign_out",
web::get().to(auth_controller::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())? .bind(AppConfig::get().listen_address.as_str())?
.run() .run()

View File

@ -1 +1,2 @@
pub mod tokens;
pub mod users; pub mod users;

View File

@ -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<String>,
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<ipnet::IpNet> {
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,
}

View File

@ -19,6 +19,7 @@ pub async fn main_routine() {
} }
async fn exec_routine() -> anyhow::Result<()> { async fn exec_routine() -> anyhow::Result<()> {
// TODO // TODO : remove orphan attachment
// TODO : remove outdated tokens
Ok(()) Ok(())
} }

View File

@ -55,22 +55,21 @@ diesel::table! {
token (id) { token (id) {
id -> Int4, id -> Int4,
#[max_length = 150] #[max_length = 150]
label -> Varchar, name -> Varchar,
time_create -> Int8, time_create -> Int8,
time_update -> Int8,
user_id -> Int4, user_id -> Int4,
#[max_length = 150] #[max_length = 150]
token_value -> Varchar, token_value -> Varchar,
time_used -> Int8, time_used -> Int8,
max_inactivity -> Nullable<Int4>, max_inactivity -> Int4,
#[max_length = 50] #[max_length = 50]
ip_restriction -> Nullable<Varchar>, ip_net -> Nullable<Varchar>,
read_only -> Bool, read_only -> Bool,
right_account -> Bool, right_account -> Bool,
right_movement -> Bool, right_movement -> Bool,
right_inbox -> Bool, right_inbox -> Bool,
right_attachment -> Bool, right_attachment -> Bool,
right_user -> Bool, right_auth -> Bool,
} }
} }

View File

@ -1 +1,2 @@
pub mod tokens_service;
pub mod users_service; pub mod users_service;

View File

@ -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<ipnet::IpNet>,
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<Token> {
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<Token> {
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<Token> {
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<Token> {
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<Vec<Token>> {
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(())
}