From 4a477e478ed0e001cf0caaa5175253196ce94ada Mon Sep 17 00:00:00 2001 From: JMARyA Date: Mon, 5 May 2025 12:04:12 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20based=5Fauth=20crate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 19 +++ Cargo.toml | 1 + src/asset.rs | 17 +-- src/auth/csrf.rs | 47 ------- src/auth/mod.rs | 32 ----- src/auth/profile_pic.rs | 26 ---- src/auth/session.rs | 121 ------------------ src/auth/user.rs | 252 ------------------------------------- src/lib.rs | 4 +- src/ui/components/modal.rs | 45 +++---- src/ui/components/shell.rs | 11 +- src/ui/primitives/grid.rs | 34 ++--- 12 files changed, 83 insertions(+), 526 deletions(-) delete mode 100644 src/auth/csrf.rs delete mode 100644 src/auth/mod.rs delete mode 100644 src/auth/profile_pic.rs delete mode 100644 src/auth/session.rs delete mode 100644 src/auth/user.rs diff --git a/Cargo.lock b/Cargo.lock index 836aa93..4340695 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,6 +239,7 @@ name = "based" version = "0.1.0" dependencies = [ "async-stream", + "based_auth", "bcrypt", "chrono", "dashmap", @@ -264,6 +265,24 @@ dependencies = [ "walkdir", ] +[[package]] +name = "based_auth" +version = "0.1.0" +source = "git+https://git.hydrar.de/jmarya/based_auth#70c87bfad5ba066220d9c22a0c03668865b2e0ff" +dependencies = [ + "bcrypt", + "chrono", + "data-encoding", + "env_logger 0.10.2", + "hex", + "log", + "owl", + "rand 0.8.5", + "rocket", + "serde", + "uuid", +] + [[package]] name = "bcrypt" version = "0.16.0" diff --git a/Cargo.toml b/Cargo.toml index 218d4c4..bae4f7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +based_auth = { git = "https://git.hydrar.de/jmarya/based_auth" } env_logger = "0.10.0" hex = "0.4.3" rayon = "1.7.0" diff --git a/src/asset.rs b/src/asset.rs index 0239eda..2a929f9 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -52,12 +52,15 @@ pub trait AssetRoutes { impl AssetRoutes for rocket::Rocket { fn mount_assets(self) -> Self { - self.mount("/", routes![ - crate::asset::htmx_script_route, - crate::asset::flowbite_css, - crate::asset::flowbite_js, - crate::asset::material_css, - crate::asset::material_font - ]) + self.mount( + "/", + routes![ + crate::asset::htmx_script_route, + crate::asset::flowbite_css, + crate::asset::flowbite_js, + crate::asset::material_css, + crate::asset::material_font + ], + ) } } diff --git a/src/auth/csrf.rs b/src/auth/csrf.rs deleted file mode 100644 index 4ebd063..0000000 --- a/src/auth/csrf.rs +++ /dev/null @@ -1,47 +0,0 @@ -use maud::PreEscaped; -use owl::{query, update}; - -use super::User; -use crate::{auth::Session, ui::prelude::script}; -use std::str::FromStr; - -pub trait CSRF { - fn get_csrf(&self) -> impl std::future::Future; - fn verify_csrf(&self, csrf: &str) -> impl std::future::Future; - fn update_csrf(&self) -> impl std::future::Future>; -} - -impl CSRF for User { - /// Javascript to update the `value` of an element with id `csrf`. - /// - /// This is useful for htmx requests to update the CSRF token in place. - async fn update_csrf(&self) -> PreEscaped { - script(&format!( - "document.querySelectorAll('.csrf').forEach(element => {{ element.value = '{}'; }});", - self.get_csrf().await - )) - } - - /// Get CSRF Token for the current session - async fn get_csrf(&self) -> uuid::Uuid { - assert!(!self.session.is_empty()); - - let res = query!(|s: &Session| { s.token == self.session }); - - res.first().unwrap().read().csrf - } - - /// Verify CSRF and generate a new one - async fn verify_csrf(&self, csrf: &str) -> bool { - if self.get_csrf().await == uuid::Uuid::from_str(csrf).unwrap_or_default() { - let mut res = query!(|s: &Session| { s.token == self.session }); - update!(&mut res, |s: &mut Session| { - s.csrf = uuid::Uuid::new_v4(); - }); - - return true; - } - - false - } -} diff --git a/src/auth/mod.rs b/src/auth/mod.rs deleted file mode 100644 index c6b8f25..0000000 --- a/src/auth/mod.rs +++ /dev/null @@ -1,32 +0,0 @@ -pub mod csrf; -pub mod profile_pic; -mod session; -mod user; -pub use session::Session; -pub use session::Sessions; -pub use user::APIUser; -pub use user::AdminUser; -pub use user::MaybeUser; -pub use user::User; -pub use user::UserAuth; -pub use user::UserRole; - -/// A macro to check if a user has admin privileges. -/// -/// This macro checks whether the provided user has admin privileges by calling the `is_admin` method on it. -/// If the user is not an admin, it returns a `Forbidden` error with a message indicating the restriction. -/// -/// # Arguments -/// * `$u` - The user to check. -/// -/// # Returns -/// The macro does not return a value directly but controls the flow of execution. If the user is not an admin, -/// it returns a `Forbidden` error immediately and prevents further execution. -#[macro_export] -macro_rules! check_admin { - ($u:ident) => { - if !$u.is_admin() { - return Err($crate::request::api::api_error("Forbidden")); - } - }; -} diff --git a/src/auth/profile_pic.rs b/src/auth/profile_pic.rs deleted file mode 100644 index 037d417..0000000 --- a/src/auth/profile_pic.rs +++ /dev/null @@ -1,26 +0,0 @@ -use super::User; -use owl::{db::model::file::File, dereference, get, prelude::*, update}; -pub trait ProfilePic { - fn profile_pic(&self) -> impl std::future::Future>>; - fn set_profile_pic(&self, image: Vec) -> impl std::future::Future; -} - -impl ProfilePic for User { - /// Get a user's profile picture from the database - async fn profile_pic(&self) -> Option> { - self.profile_picture - .as_ref() - .map(|x| dereference!(x).read_file(&owl::DB.get().unwrap())) - } - - /// Set a user's profile picture in the database - async fn set_profile_pic(&self, image: Vec) { - let mut target = vec![get!(self.id.clone()).unwrap()]; - - let file = File::new(image, None, &owl::DB.get().unwrap()); - - update!(&mut target, |u: &mut User| { - u.profile_picture = Some(file.reference()); - }) - } -} diff --git a/src/auth/session.rs b/src/auth/session.rs deleted file mode 100644 index 444286a..0000000 --- a/src/auth/session.rs +++ /dev/null @@ -1,121 +0,0 @@ -use chrono::Utc; -use owl::{dereference, prelude::*, query, save}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::gen_random; - -use super::{User, UserRole}; - -#[derive(Debug, Clone)] -#[model] -pub struct Session { - /// The unique ID of the session token - pub id: Id, - /// The generated session token - pub token: String, - /// The username associated with the session token - pub user: IdRef, - /// Session creation time - pub created: chrono::DateTime, - /// Internal CSRF value - pub csrf: uuid::Uuid, - /// Named session value - pub name: Option, - /// Kind of session - pub kind: SessionKind, -} - -#[allow(clippy::upper_case_acronyms)] -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "session_kind", rename_all = "lowercase")] -pub enum SessionKind { - API, - USER, -} - -pub trait Sessions { - fn from_session(session: String) -> impl std::future::Future>>; - fn login( - username: &str, - password: &str, - ) -> impl std::future::Future, UserRole)>>; - fn api_key(&self, name: &str) -> impl std::future::Future>; - fn session(&self) -> impl std::future::Future>; - fn list_sessions(&self) -> impl std::future::Future>>; - fn end_session(&self, id: &uuid::Uuid) -> impl std::future::Future; -} - -impl Sessions for User { - /// Generate a new API Key session - async fn api_key(&self, name: &str) -> Model { - save!(Session { - id: Id::new_ulid(), - token: gen_random(64), - user: self.reference(), - created: chrono::Utc::now(), - csrf: Uuid::new_v4(), - name: Some(name.to_string()), - kind: SessionKind::API - }) - } - - /// End a user session - async fn end_session(&self, id: &uuid::Uuid) { - /* TODO : deletion - sqlx::query("DELETE FROM user_session WHERE id = $1 AND \"user\" = $2") - .bind(id) - .bind(&self.username) - .execute(get_pg!()) - .await - .unwrap(); - */ - } - - /// Get all sessions for a user - async fn list_sessions(&self) -> Vec> { - query!(|ses: &Session| ses.user.to_string() == self.reference().to_string()) - } - - // Get a user from session ID - async fn from_session(session_token: String) -> Option> { - let session = query!(|ses: &Session| ses.token == session_token); - let session = session.first(); - if let Some(ses) = session { - let mut user = dereference!(ses.read().user); - user.write_raw_inline(|u: &mut _| { - u.session = session_token.to_string(); - }); - return Some(user); - } - - None - } - - /// Login a user with the given username and password - async fn login(username: &str, password: &str) -> Option<(Model, UserRole)> { - let u = Self::find(username).await?; - let u = u.read(); - - if !u.verify_pw(password) { - return None; - } - - Some((u.session().await, u.user_role.clone())) - } - - /// Generate a new session token for the user - /// - /// Returns a Session instance containing the generated token and associated user - async fn session(&self) -> Model { - save!(Session { - id: Id::new_ulid(), - token: gen_random(64), - user: self.reference(), - created: chrono::Utc::now(), - csrf: Uuid::new_v4(), - name: None, - kind: SessionKind::USER - }) - } -} diff --git a/src/auth/user.rs b/src/auth/user.rs deleted file mode 100644 index 6c62c42..0000000 --- a/src/auth/user.rs +++ /dev/null @@ -1,252 +0,0 @@ -use owl::{db::model::file::File, get, prelude::*, query, save, update}; -use rocket::{Request, http::Status, outcome::Outcome, request::FromRequest}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use super::Sessions; -use crate::request::api::ToAPI; - -// TODO : 2FA - -/// User -/// -/// # Example: -/// -/// ```ignore -/// -/// // Needs login -/// #[get("/myaccount")] -/// pub async fn account_page(ctx: RequestContext, user: User) -> StringResponse { -/// ... -/// } -/// ``` -#[derive(Debug, Clone)] -#[model] -pub struct User { - /// The username chosen by the user - pub id: Id, - /// The hashed password for the user - pub password: String, - /// The role of the user - pub user_role: UserRole, - #[serde(skip)] - pub session: String, - pub profile_picture: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "user_role", rename_all = "lowercase")] -pub enum UserRole { - /// A regular user with limited permissions - Regular, - /// An admin user with full system privileges - Admin, -} - -impl User { - /// Find a user by their username - pub async fn find(username: &str) -> Option> { - get!(username) - } - - /// Create a new user with the given details - /// - /// Returns an Option containing the created user, or None if a user already exists with the same username - pub async fn create(username: String, password: &str, role: UserRole) -> Option> { - // Check if a user already exists with the same username - if Self::find(&username).await.is_some() { - return None; - } - - let u = Self { - id: Id::String(username), - password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(), - user_role: role, - profile_picture: None, - session: String::new(), - }; - - Some(save!(u)) - } - - /// Change the password of a User - /// - /// Returns a Result indicating whether the password change was successful or not - pub async fn passwd(&self, old: &str, new: &str) -> Result<(), ()> { - if self.verify_pw(old) { - let mut target = vec![get!(self.id.clone()).unwrap()]; - update!(&mut target, |u: &mut User| { - u.password = bcrypt::hash(new, bcrypt::DEFAULT_COST).unwrap(); - }); - - return Ok(()); - } - - Err(()) - } - - /// Find all users in the system - #[must_use] - pub async fn find_all() -> Vec> { - query!(|_| true) - } - - /// Check if the user is an admin - #[must_use] - pub const fn is_admin(&self) -> bool { - matches!(self.user_role, UserRole::Admin) - } - - /// Verify that a provided password matches the hashed password for the user - /// - /// Returns a boolean indicating whether the passwords match or not - #[must_use] - pub fn verify_pw(&self, password: &str) -> bool { - bcrypt::verify(password, &self.password).unwrap() - } -} - -impl ToAPI for User { - async fn api(&self) -> serde_json::Value { - json!({ - "username": self.id.to_string(), - "role": self.user_role - }) - } -} - -/// extracts a user from a request with `session` cookie -async fn extract_user(request: &Request<'_>) -> Option> { - if let Some(session_id) = request.cookies().get("session") { - if let Some(user) = User::from_session(session_id.value().to_string()).await { - return Some(user); - } - return None; - } - - None -} - -pub struct UserAuth(pub Model); - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for UserAuth { - type Error = (); - - async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome { - if let Some(user) = extract_user(request).await { - return Outcome::Success(UserAuth(user)); - } - Outcome::Error((Status::Unauthorized, ())) - } -} - -/// Struct which extracts a user with session from `Token` HTTP Header. -pub struct APIUser(pub Model); - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for APIUser { - type Error = (); - - async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome { - match request.headers().get_one("token") { - Some(key) => { - if let Some(user) = User::from_session(key.to_string()).await { - return Outcome::Success(APIUser(user)); - } - return Outcome::Error((Status::Unauthorized, ())); - } - None => Outcome::Error((Status::Unauthorized, ())), - } - } -} - -/// Maybe User? -/// -/// This struct extracts a user if possible, but also allows anybody. -/// -/// # Example: -/// -/// ```ignore -/// -/// // Publicly accessable -/// #[get("/")] -/// pub async fn index(ctx: RequestContext, user: MaybeUser) -> StringResponse { -/// match user { -/// MaybeUser::User(user) => println!("You are {}", user.username), -/// MaybeUser::Anonymous => println!("Who are you?") -/// } -/// } -/// ``` -pub enum MaybeUser { - User(Model), - Anonymous, -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for MaybeUser { - type Error = (); - - async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome { - if let Some(user) = extract_user(request).await { - return Outcome::Success(MaybeUser::User(user)); - } - - Outcome::Success(MaybeUser::Anonymous) - } -} - -impl From for Option> { - fn from(value: MaybeUser) -> Self { - value.take_user() - } -} - -impl MaybeUser { - #[must_use] - pub const fn user(&self) -> Option<&Model> { - match self { - MaybeUser::User(user) => Some(user), - MaybeUser::Anonymous => None, - } - } - - #[must_use] - pub fn take_user(self) -> Option> { - match self { - MaybeUser::User(user) => Some(user), - MaybeUser::Anonymous => None, - } - } -} - -/// Admin User -/// -/// This struct expects an Admin User and returns `Forbidden` otherwise. -/// -/// # Example: -/// -/// ```ignore -/// -/// // Only admin users can access this route -/// #[get("/admin")] -/// pub async fn admin_panel(ctx: RequestContext, user: AdminUser) -> StringResponse { -/// ... -/// } -/// ``` -pub struct AdminUser(pub Model); - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for AdminUser { - type Error = (); - - async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome { - if let Some(user) = extract_user(request).await { - if user.read().is_admin() { - return Outcome::Success(AdminUser(user)); - } - } - - Outcome::Error((Status::Unauthorized, ())) - } -} diff --git a/src/lib.rs b/src/lib.rs index a7659bf..bcc915a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,9 @@ use rand::RngCore; use tokio::sync::OnceCell; pub mod asset; -pub mod auth; +pub mod auth { + pub use based_auth::*; +} pub mod format; pub mod ogp; pub mod request; diff --git a/src/ui/components/modal.rs b/src/ui/components/modal.rs index a248df5..e062afd 100644 --- a/src/ui/components/modal.rs +++ b/src/ui/components/modal.rs @@ -29,29 +29,32 @@ pub fn Modal ) -> (String, PreEscaped) { let id = uuid::Uuid::new_v4().to_string(); - (format!("modal-{id}"), html! { - div id=(format!("modal-{id}")) tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full" { - div class="relative p-4 w-full max-w-2xl max-h-full" { + ( + format!("modal-{id}"), + html! { + div id=(format!("modal-{id}")) tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full" { + div class="relative p-4 w-full max-w-2xl max-h-full" { - div class="relative bg-white rounded-lg shadow dark:bg-gray-700" { - div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600" { - h3 class="text-xl font-semibold text-gray-900 dark:text-white" { (title) } - button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide=(format!("modal-{id}")) { - svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" { - path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {}; - }; - span class="sr-only" { "Close modal" }; - } - }; + div class="relative bg-white rounded-lg shadow dark:bg-gray-700" { + div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600" { + h3 class="text-xl font-semibold text-gray-900 dark:text-white" { (title) } + button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide=(format!("modal-{id}")) { + svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {}; + }; + span class="sr-only" { "Close modal" }; + } + }; - div class="p-4 md:p-5 space-y-4" { - (body) - }; + div class="p-4 md:p-5 space-y-4" { + (body) + }; - div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600" { - (footer(format!("modal-{id}"))) + div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600" { + (footer(format!("modal-{id}"))) + }; }; - }; - }}; - }) + }}; + }, + ) } diff --git a/src/ui/components/shell.rs b/src/ui/components/shell.rs index aac9ab7..df00f44 100644 --- a/src/ui/components/shell.rs +++ b/src/ui/components/shell.rs @@ -287,10 +287,13 @@ pub fn BottomNavigationTile( ) -> ClassicWidget { Classic( "inline-flex flex-col items-center justify-center px-5 hover:bg-gray-50 dark:hover:bg-gray-800 group", - Link(reference, html! { - (icon.map(|x| x.render()).unwrap_or_default()); - span class="text-sm text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" { (text) }; - }), + Link( + reference, + html! { + (icon.map(|x| x.render()).unwrap_or_default()); + span class="text-sm text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" { (text) }; + }, + ), ) } diff --git a/src/ui/primitives/grid.rs b/src/ui/primitives/grid.rs index cd1e345..31d46c1 100644 --- a/src/ui/primitives/grid.rs +++ b/src/ui/primitives/grid.rs @@ -274,21 +274,25 @@ impl GridElement { } pub fn span(mut self, value: GridElementValue) -> Self { - self.1.push(format!("{}-span-{}", self.2, match value { - GridElementValue::_1 => "1", - GridElementValue::_2 => "2", - GridElementValue::_3 => "3", - GridElementValue::_4 => "4", - GridElementValue::_5 => "5", - GridElementValue::_6 => "6", - GridElementValue::_7 => "7", - GridElementValue::_8 => "8", - GridElementValue::_9 => "9", - GridElementValue::_10 => "10", - GridElementValue::_11 => "11", - GridElementValue::_12 => "12", - GridElementValue::Auto => "full", - })); + self.1.push(format!( + "{}-span-{}", + self.2, + match value { + GridElementValue::_1 => "1", + GridElementValue::_2 => "2", + GridElementValue::_3 => "3", + GridElementValue::_4 => "4", + GridElementValue::_5 => "5", + GridElementValue::_6 => "6", + GridElementValue::_7 => "7", + GridElementValue::_8 => "8", + GridElementValue::_9 => "9", + GridElementValue::_10 => "10", + GridElementValue::_11 => "11", + GridElementValue::_12 => "12", + GridElementValue::Auto => "full", + } + )); self }