based_auth crate

This commit is contained in:
JMARyA 2025-05-05 12:04:12 +02:00
parent 2f2d77ec48
commit 4a477e478e
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
12 changed files with 83 additions and 526 deletions

19
Cargo.lock generated
View file

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

View file

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

View file

@ -52,12 +52,15 @@ pub trait AssetRoutes {
impl AssetRoutes for rocket::Rocket<Build> {
fn mount_assets(self) -> Self {
self.mount("/", routes![
self.mount(
"/",
routes![
crate::asset::htmx_script_route,
crate::asset::flowbite_css,
crate::asset::flowbite_js,
crate::asset::material_css,
crate::asset::material_font
])
],
)
}
}

View file

@ -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<Output = uuid::Uuid>;
fn verify_csrf(&self, csrf: &str) -> impl std::future::Future<Output = bool>;
fn update_csrf(&self) -> impl std::future::Future<Output = PreEscaped<String>>;
}
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<String> {
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
}
}

View file

@ -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"));
}
};
}

View file

@ -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<Output = Option<Vec<u8>>>;
fn set_profile_pic(&self, image: Vec<u8>) -> impl std::future::Future<Output = ()>;
}
impl ProfilePic for User {
/// Get a user's profile picture from the database
async fn profile_pic(&self) -> Option<Vec<u8>> {
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<u8>) {
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());
})
}
}

View file

@ -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<User>,
/// Session creation time
pub created: chrono::DateTime<Utc>,
/// Internal CSRF value
pub csrf: uuid::Uuid,
/// Named session value
pub name: Option<String>,
/// 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<Output = Option<Model<User>>>;
fn login(
username: &str,
password: &str,
) -> impl std::future::Future<Output = Option<(Model<Session>, UserRole)>>;
fn api_key(&self, name: &str) -> impl std::future::Future<Output = Model<Session>>;
fn session(&self) -> impl std::future::Future<Output = Model<Session>>;
fn list_sessions(&self) -> impl std::future::Future<Output = Vec<Model<Session>>>;
fn end_session(&self, id: &uuid::Uuid) -> impl std::future::Future<Output = ()>;
}
impl Sessions for User {
/// Generate a new API Key session
async fn api_key(&self, name: &str) -> Model<Session> {
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<Model<Session>> {
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<Model<User>> {
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<Session>, 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<Session> {
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
})
}
}

View file

@ -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<IdRef<File>>,
}
#[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<Model<Self>> {
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<Model<Self>> {
// 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<Model<Self>> {
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<Model<User>> {
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<User>);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for UserAuth {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
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<User>);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for APIUser {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
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<User>),
Anonymous,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for MaybeUser {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
if let Some(user) = extract_user(request).await {
return Outcome::Success(MaybeUser::User(user));
}
Outcome::Success(MaybeUser::Anonymous)
}
}
impl From<MaybeUser> for Option<Model<User>> {
fn from(value: MaybeUser) -> Self {
value.take_user()
}
}
impl MaybeUser {
#[must_use]
pub const fn user(&self) -> Option<&Model<User>> {
match self {
MaybeUser::User(user) => Some(user),
MaybeUser::Anonymous => None,
}
}
#[must_use]
pub fn take_user(self) -> Option<Model<User>> {
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<User>);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminUser {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
if let Some(user) = extract_user(request).await {
if user.read().is_admin() {
return Outcome::Success(AdminUser(user));
}
}
Outcome::Error((Status::Unauthorized, ()))
}
}

View file

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

View file

@ -29,7 +29,9 @@ pub fn Modal<T: UIWidget + 'static, E: UIWidget + 'static, F: FnOnce(String) ->
) -> (String, PreEscaped<String>) {
let id = uuid::Uuid::new_v4().to_string();
(format!("modal-{id}"), html! {
(
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" {
@ -53,5 +55,6 @@ pub fn Modal<T: UIWidget + 'static, E: UIWidget + 'static, F: FnOnce(String) ->
};
};
}};
})
},
)
}

View file

@ -287,10 +287,13 @@ pub fn BottomNavigationTile<T: UIWidget + 'static>(
) -> ClassicWidget<LinkWidget> {
Classic(
"inline-flex flex-col items-center justify-center px-5 hover:bg-gray-50 dark:hover:bg-gray-800 group",
Link(reference, html! {
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) };
}),
},
),
)
}

View file

@ -274,7 +274,10 @@ impl GridElement {
}
pub fn span(mut self, value: GridElementValue) -> Self {
self.1.push(format!("{}-span-{}", self.2, match value {
self.1.push(format!(
"{}-span-{}",
self.2,
match value {
GridElementValue::_1 => "1",
GridElementValue::_2 => "2",
GridElementValue::_3 => "3",
@ -288,7 +291,8 @@ impl GridElement {
GridElementValue::_11 => "11",
GridElementValue::_12 => "12",
GridElementValue::Auto => "full",
}));
}
));
self
}