From d7a55f6579b119fcb081c5dfeb747b4237bf17c5 Mon Sep 17 00:00:00 2001 From: JMARyA Date: Tue, 24 Dec 2024 13:17:01 +0100 Subject: [PATCH 1/2] add session creation ts --- src/auth/auth.sql | 15 +++++++++++++++ src/auth/mod.rs | 3 +++ src/auth/user.rs | 11 ++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/auth/auth.sql diff --git a/src/auth/auth.sql b/src/auth/auth.sql new file mode 100644 index 0000000..fb95da5 --- /dev/null +++ b/src/auth/auth.sql @@ -0,0 +1,15 @@ +CREATE TYPE user_role AS ENUM ('regular', 'admin'); + +CREATE TABLE IF NOT EXISTS users ( + username VARCHAR(255) NOT NULL PRIMARY KEY, + "password" text NOT NULL, + user_role user_role NOT NULL DEFAULT 'regular' +); + +CREATE TABLE IF NOT EXISTS user_session ( + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + token text NOT NULL, + "user" varchar(255) NOT NULL, + "created" timestamptz NOT NULL DEFAULT NOW(), + FOREIGN KEY("user") REFERENCES users(username) +); \ No newline at end of file diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 82585bb..74d429a 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,3 +1,4 @@ +use chrono::Utc; use data_encoding::HEXUPPER; use rand::RngCore; use serde::{Deserialize, Serialize}; @@ -25,6 +26,8 @@ pub struct Session { pub token: String, /// The username associated with the session token pub user: String, + /// Session creation time + pub created: chrono::DateTime } /// A macro to check if a user has admin privileges. diff --git a/src/auth/user.rs b/src/auth/user.rs index ad85fb0..0b960b5 100644 --- a/src/auth/user.rs +++ b/src/auth/user.rs @@ -26,6 +26,8 @@ pub struct User { pub password: String, /// The role of the user pub user_role: UserRole, + #[sqlx(default)] + pub session: String } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] @@ -40,7 +42,14 @@ pub enum UserRole { impl User { // Get a user from session ID pub async fn from_session(session: &str) -> Option { - sqlx::query_as("SELECT * FROM users WHERE username = (SELECT \"user\" FROM user_session WHERE token = $1)").bind(session).fetch_optional(get_pg!()).await.unwrap() + let user: Option = sqlx::query_as("SELECT * FROM users WHERE username = (SELECT \"user\" FROM user_session WHERE token = $1)").bind(session).fetch_optional(get_pg!()).await.unwrap(); + + if let Some(mut user) = user { + user.session = session.to_string(); + return Some(user); + } + + None } /// Find a user by their username From e5fe40e4bed87f9a719fbf1fb0ad4a0bdb0d5934 Mon Sep 17 00:00:00 2001 From: JMARyA Date: Fri, 27 Dec 2024 03:56:27 +0100 Subject: [PATCH 2/2] user update --- README.md | 19 +------ src/auth/auth.sql | 9 ++++ src/auth/csrf.rs | 36 +++++++++++++ src/auth/mod.rs | 20 ++----- src/auth/profile_pic.rs | 31 +++++++++++ src/auth/session.rs | 116 ++++++++++++++++++++++++++++++++++++++++ src/auth/user.rs | 45 +++------------- src/lib.rs | 6 +-- 8 files changed, 205 insertions(+), 77 deletions(-) create mode 100644 src/auth/csrf.rs create mode 100644 src/auth/profile_pic.rs create mode 100644 src/auth/session.rs diff --git a/README.md b/README.md index 994c265..512c039 100644 --- a/README.md +++ b/README.md @@ -9,24 +9,7 @@ Based is a micro framework providing web dev primitives. - Templates (Shell) ## User Auth -To use the user auth feature, make sure a migration has added the following to your PostgresDB: - -```sql -CREATE TYPE user_role AS ENUM ('regular', 'admin'); - -CREATE TABLE IF NOT EXISTS users ( - username VARCHAR(255) NOT NULL PRIMARY KEY, - "password" text NOT NULL, - user_role user_role NOT NULL DEFAULT 'regular' -); - -CREATE TABLE IF NOT EXISTS user_session ( - id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), - token text NOT NULL, - "user" varchar(255) NOT NULL, - FOREIGN KEY("user") REFERENCES users(username) -); -``` +To use the user auth feature, make sure a migration has added [these tables](src/auth/auth.sql) to your PostgresDB: ## HTMX Based has a route for serving HTMX at `based::htmx::htmx_script_route` which you can include in your rocket `routes!`. The HTMX script will be available at `/assets/htmx.min.js`. diff --git a/src/auth/auth.sql b/src/auth/auth.sql index fb95da5..704a007 100644 --- a/src/auth/auth.sql +++ b/src/auth/auth.sql @@ -1,4 +1,5 @@ CREATE TYPE user_role AS ENUM ('regular', 'admin'); +CREATE TYPE session_kind AS ENUM ('api', 'user'); CREATE TABLE IF NOT EXISTS users ( username VARCHAR(255) NOT NULL PRIMARY KEY, @@ -11,5 +12,13 @@ CREATE TABLE IF NOT EXISTS user_session ( token text NOT NULL, "user" varchar(255) NOT NULL, "created" timestamptz NOT NULL DEFAULT NOW(), + "csrf" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" varchar(255) + kind session_kind NOT NULL DEFAULT 'user', FOREIGN KEY("user") REFERENCES users(username) +); + +CREATE TABLE IF NOT EXISTS user_profile_pic ( + username VARCHAR(255) NOT NULL PRIMARY KEY, + "image" bytea NOT NULL ); \ No newline at end of file diff --git a/src/auth/csrf.rs b/src/auth/csrf.rs new file mode 100644 index 0000000..74cb84d --- /dev/null +++ b/src/auth/csrf.rs @@ -0,0 +1,36 @@ +use super::User; +use crate::get_pg; +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; +} + +impl CSRF for User { + /// Get CSRF Token for the current session + async fn get_csrf(&self) -> uuid::Uuid { + let res: (uuid::Uuid,) = sqlx::query_as("SELECT csrf FROM user_session WHERE token = $1") + .bind(&self.session) + .fetch_one(get_pg!()) + .await + .unwrap(); + + res.0 + } + + /// 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() { + sqlx::query("UPDATE user_session SET csrf = gen_random_uuid() WHERE token = $1") + .bind(&self.session) + .execute(get_pg!()) + .await + .unwrap(); + + return true; + } + + false + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 74d429a..76067d7 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,10 +1,12 @@ -use chrono::Utc; use data_encoding::HEXUPPER; use rand::RngCore; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; +pub mod csrf; +pub mod profile_pic; +mod session; mod user; +pub use session::Session; +pub use session::Sessions; pub use user::AdminUser; pub use user::MaybeUser; pub use user::User; @@ -18,18 +20,6 @@ fn gen_token(token_length: usize) -> String { HEXUPPER.encode(&token_bytes) } -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct Session { - /// The unique ID of the session token - pub id: uuid::Uuid, - /// The generated session token - pub token: String, - /// The username associated with the session token - pub user: String, - /// Session creation time - pub created: chrono::DateTime -} - /// 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. diff --git a/src/auth/profile_pic.rs b/src/auth/profile_pic.rs new file mode 100644 index 0000000..71b4d5c --- /dev/null +++ b/src/auth/profile_pic.rs @@ -0,0 +1,31 @@ +use crate::get_pg; + +use super::User; +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> { + let res: Option<(Vec,)> = + sqlx::query_as("SELECT image FROM user_profile_pic WHERE username = $1;") + .bind(&self.username) + .fetch_optional(get_pg!()) + .await + .unwrap(); + + res.map(|x| x.0) + } + + /// Set a user's profile picture in the database + async fn set_profile_pic(&self, image: Vec) { + sqlx::query("INSERT INTO user_profile_pic (username, image) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET image = EXCLUDED.image", + ) + .bind(&self.username) + .bind(&image) + .execute(get_pg!()) + .await.unwrap(); + } +} diff --git a/src/auth/session.rs b/src/auth/session.rs new file mode 100644 index 0000000..f7fa004 --- /dev/null +++ b/src/auth/session.rs @@ -0,0 +1,116 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +use crate::get_pg; + +use super::{User, UserRole, gen_token}; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Session { + /// The unique ID of the session token + pub id: uuid::Uuid, + /// The generated session token + pub token: String, + /// The username associated with the session token + pub user: String, + /// Session creation time + pub created: chrono::DateTime, + /// Internal CSRF value + csrf: uuid::Uuid, + /// Named session value + pub name: Option, + /// Kind of session + pub kind: SessionKind, +} + +#[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: &str) -> impl std::future::Future>; + fn login( + username: &str, + password: &str, + ) -> impl std::future::Future>; + 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) -> impl std::future::Future; +} + +impl Sessions for User { + /// Generate a new API Key session + async fn api_key(&self, name: &str) -> Session { + sqlx::query_as( + "INSERT INTO user_session (token, \"user\", kind, name) VALUES ($1, $2, $3) RETURNING *", + ) + .bind(gen_token(64)) + .bind(&self.username) + .bind(SessionKind::API) + .bind(name) + .fetch_one(get_pg!()) + .await + .unwrap() + } + + /// End a user session + async fn end_session(&self) -> () { + sqlx::query("DELETE FROM user_session WHERE token = $1") + .bind(&self.session) + .execute(get_pg!()) + .await + .unwrap(); + } + + /// Get all sessions for a user + async fn list_sessions(&self) -> Vec { + sqlx::query_as("SELECT * FROM user_session WHERE user = $1") + .bind(&self.username) + .fetch_all(get_pg!()) + .await + .unwrap() + } + + // Get a user from session ID + async fn from_session(session: &str) -> Option { + let user: Option = sqlx::query_as("SELECT * FROM users WHERE username = (SELECT \"user\" FROM user_session WHERE token = $1)").bind(session).fetch_optional(get_pg!()).await.unwrap(); + + if let Some(mut user) = user { + user.session = session.to_string(); + return Some(user); + } + + None + } + + /// Login a user with the given username and password + async fn login(username: &str, password: &str) -> Option<(Session, UserRole)> { + let u = Self::find(username).await?; + + if !u.verify_pw(password) { + return None; + } + + Some((u.session().await, u.user_role)) + } + + /// Generate a new session token for the user + /// + /// Returns a Session instance containing the generated token and associated user + async fn session(&self) -> Session { + sqlx::query_as( + "INSERT INTO user_session (token, \"user\", kind) VALUES ($1, $2, $3) RETURNING *", + ) + .bind(gen_token(64)) + .bind(&self.username) + .bind(SessionKind::USER) + .fetch_one(get_pg!()) + .await + .unwrap() + } +} diff --git a/src/auth/user.rs b/src/auth/user.rs index 0b960b5..a710037 100644 --- a/src/auth/user.rs +++ b/src/auth/user.rs @@ -3,9 +3,11 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::FromRow; -use super::{Session, gen_token}; +use super::Sessions; use crate::{get_pg, request::api::ToAPI}; +// TODO : 2FA + /// User /// /// # Example: @@ -27,7 +29,7 @@ pub struct User { /// The role of the user pub user_role: UserRole, #[sqlx(default)] - pub session: String + pub session: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] @@ -40,18 +42,6 @@ pub enum UserRole { } impl User { - // Get a user from session ID - pub async fn from_session(session: &str) -> Option { - let user: Option = sqlx::query_as("SELECT * FROM users WHERE username = (SELECT \"user\" FROM user_session WHERE token = $1)").bind(session).fetch_optional(get_pg!()).await.unwrap(); - - if let Some(mut user) = user { - user.session = session.to_string(); - return Some(user); - } - - None - } - /// Find a user by their username pub async fn find(username: &str) -> Option { sqlx::query_as("SELECT * FROM users WHERE username = $1") @@ -74,6 +64,7 @@ impl User { username: username.to_string(), password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(), user_role: role, + session: String::new(), }; sqlx::query("INSERT INTO users (username, \"password\", user_role) VALUES ($1, $2, $3)") @@ -87,17 +78,6 @@ impl User { Some(u) } - /// Login a user with the given username and password - pub async fn login(username: &str, password: &str) -> Option<(Session, UserRole)> { - let u = Self::find(username).await?; - - if !u.verify_pw(password) { - return None; - } - - Some((u.session().await, u.user_role)) - } - /// Change the password of a User /// /// Returns a Result indicating whether the password change was successful or not @@ -124,20 +104,6 @@ impl User { .unwrap() } - /// Generate a new session token for the user - /// - /// Returns a Session instance containing the generated token and associated user - pub async fn session(&self) -> Session { - sqlx::query_as( - "INSERT INTO user_session (token, \"user\") VALUES ($1, $2) RETURNING id, token, \"user\"", - ) - .bind(gen_token(64)) - .bind(&self.username) - .fetch_one(get_pg!()) - .await - .unwrap() - } - /// Check if the user is an admin pub const fn is_admin(&self) -> bool { matches!(self.user_role, UserRole::Admin) @@ -160,6 +126,7 @@ impl ToAPI for User { } } +/// extracts a user from a request with `session` cookie async fn extract_user<'r>(request: &'r Request<'_>) -> Option { if let Some(session_id) = request.cookies().get("session") { if let Some(user) = User::from_session(session_id.value()).await { diff --git a/src/lib.rs b/src/lib.rs index 10dda8e..8acc857 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,17 +2,13 @@ use tokio::sync::OnceCell; pub mod auth; pub mod format; +pub mod htmx; pub mod page; pub mod request; pub mod result; -pub mod htmx; -// TODO : Cache Headers -// TODO : Refactor Responders -// TODO : Streaming Responses + Ranges // TODO : API Pagination? // TODO : CORS? -// TODO : CSRF? // Postgres