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"
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",

View File

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

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
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::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
}

View File

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

View File

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

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::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<Self, Self::Error>>;

View File

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

View File

@ -1 +1,2 @@
pub mod tokens;
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<()> {
// TODO
// TODO : remove orphan attachment
// TODO : remove outdated tokens
Ok(())
}

View File

@ -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<Int4>,
max_inactivity -> Int4,
#[max_length = 50]
ip_restriction -> Nullable<Varchar>,
ip_net -> Nullable<Varchar>,
read_only -> Bool,
right_account -> Bool,
right_movement -> Bool,
right_inbox -> Bool,
right_attachment -> Bool,
right_user -> Bool,
right_auth -> Bool,
}
}

View File

@ -1 +1,2 @@
pub mod tokens_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(())
}