diff --git a/Cargo.toml b/Cargo.toml index 1b2344741..360de9e6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "packages/fullstack/examples/axum-hello-world", "packages/fullstack/examples/axum-router", "packages/fullstack/examples/axum-desktop", + "packages/fullstack/examples/axum-auth", "packages/fullstack/examples/salvo-hello-world", "packages/fullstack/examples/warp-hello-world", "packages/fullstack/examples/static-hydrated", diff --git a/packages/fullstack/examples/axum-auth/.gitignore b/packages/fullstack/examples/axum-auth/.gitignore new file mode 100644 index 000000000..b2a16319f --- /dev/null +++ b/packages/fullstack/examples/axum-auth/.gitignore @@ -0,0 +1,3 @@ +dist +target +static \ No newline at end of file diff --git a/packages/fullstack/examples/axum-auth/Cargo.toml b/packages/fullstack/examples/axum-auth/Cargo.toml new file mode 100644 index 000000000..8e8389084 --- /dev/null +++ b/packages/fullstack/examples/axum-auth/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "axum-auth" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dioxus-web = { workspace = true, features = ["hydrate"], optional = true } +dioxus = { workspace = true } +dioxus-fullstack = { workspace = true } +axum = { version = "0.6.12", optional = true } +tokio = { workspace = true, features = ["full"], optional = true } +serde = "1.0.159" +execute = "0.2.12" +tower-http = { version = "0.4.1", features = ["auth"], optional = true } +simple_logger = { version = "4.2.0", optional = true } +async-trait = { version = "0.1.71", optional = true } +sqlx = { version = "0.7.0", features = [ + "macros", + "migrate", + "postgres", + "sqlite", + "_unstable-all-types", + "tls-rustls", + "runtime-tokio", +], optional = true } +anyhow = "1.0.71" +http = { version = "0.2.9", optional = true } + +[dependencies.axum_session] +version = "0.3.0" +features = ["sqlite-rustls"] +optional = true + +[dependencies.axum_session_auth] +version = "0.3.0" +features = ["sqlite-rustls"] +optional = true + +[features] +default = [] +ssr = ["axum", "tokio", "dioxus-fullstack/axum", "tower-http", "simple_logger", "async-trait", "sqlx", "axum_session", "axum_session_auth", "http"] +web = ["dioxus-web"] + +[profile.release] +lto = true +panic = "abort" +opt-level = 'z' +strip = true +codegen-units = 1 diff --git a/packages/fullstack/examples/axum-auth/src/auth.rs b/packages/fullstack/examples/axum-auth/src/auth.rs new file mode 100644 index 000000000..9056c0c1a --- /dev/null +++ b/packages/fullstack/examples/axum-auth/src/auth.rs @@ -0,0 +1,229 @@ +use async_trait::async_trait; +use axum::{http::Method, routing::get, Router, response::{Response, IntoResponse}}; +use axum_session::{SessionConfig, SessionLayer, SessionSqlitePool, SessionStore}; +use axum_session_auth::*; +use serde::{Deserialize, Serialize}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +use std::{collections::HashSet, net::SocketAddr, str::FromStr}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: i32, + pub anonymous: bool, + pub username: String, + pub permissions: HashSet, +} + +#[derive(sqlx::FromRow, Clone)] +pub struct SqlPermissionTokens { + pub token: String, +} + +impl Default for User { + fn default() -> Self { + let mut permissions = HashSet::new(); + + permissions.insert("Category::View".to_owned()); + + Self { + id: 1, + anonymous: true, + username: "Guest".into(), + permissions, + } + } +} + +#[async_trait] +impl Authentication for User { + async fn load_user(userid: i64, pool: Option<&SqlitePool>) -> Result { + let pool = pool.unwrap(); + + User::get_user(userid, pool) + .await + .ok_or_else(|| anyhow::anyhow!("Could not load user")) + } + + fn is_authenticated(&self) -> bool { + !self.anonymous + } + + fn is_active(&self) -> bool { + !self.anonymous + } + + fn is_anonymous(&self) -> bool { + self.anonymous + } +} + +#[async_trait] +impl HasPermission for User { + async fn has(&self, perm: &str, _pool: &Option<&SqlitePool>) -> bool { + self.permissions.contains(perm) + } +} + +impl User { + pub async fn get_user(id: i64, pool: &SqlitePool) -> Option { + let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await + .ok()?; + + //lets just get all the tokens the user can use, we will only use the full permissions if modifing them. + let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>( + "SELECT token FROM user_permissions WHERE user_id = $1;", + ) + .bind(id) + .fetch_all(pool) + .await + .ok()?; + + Some(sqluser.into_user(Some(sql_user_perms))) + } + + pub async fn create_user_tables(pool: &SqlitePool) { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS users ( + "id" INTEGER PRIMARY KEY, + "anonymous" BOOLEAN NOT NULL, + "username" VARCHAR(256) NOT NULL + ) + "#, + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS user_permissions ( + "user_id" INTEGER NOT NULL, + "token" VARCHAR(256) NOT NULL + ) + "#, + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + r#" + INSERT INTO users + (id, anonymous, username) SELECT 1, true, 'Guest' + ON CONFLICT(id) DO UPDATE SET + anonymous = EXCLUDED.anonymous, + username = EXCLUDED.username + "#, + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + r#" + INSERT INTO users + (id, anonymous, username) SELECT 2, false, 'Test' + ON CONFLICT(id) DO UPDATE SET + anonymous = EXCLUDED.anonymous, + username = EXCLUDED.username + "#, + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + r#" + INSERT INTO user_permissions + (user_id, token) SELECT 2, 'Category::View' + "#, + ) + .execute(pool) + .await + .unwrap(); + } +} + +#[derive(sqlx::FromRow, Clone)] +pub struct SqlUser { + pub id: i32, + pub anonymous: bool, + pub username: String, +} + +impl SqlUser { + pub fn into_user(self, sql_user_perms: Option>) -> User { + User { + id: self.id, + anonymous: self.anonymous, + username: self.username, + permissions: if let Some(user_perms) = sql_user_perms { + user_perms + .into_iter() + .map(|x| x.token) + .collect::>() + } else { + HashSet::::new() + }, + } + } +} + +pub async fn connect_to_database() -> SqlitePool { + let connect_opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); + + SqlitePoolOptions::new() + .max_connections(5) + .connect_with(connect_opts) + .await + .unwrap() +} + +pub struct Session(pub axum_session_auth::AuthSession); + +impl std::ops::Deref for Session { + type Target = axum_session_auth::AuthSession; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for Session { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[derive(Debug)] +pub struct AuthSessionLayerNotFound; + +impl std::fmt::Display for AuthSessionLayerNotFound { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "AuthSessionLayer was not found") + } +} + +impl std::error::Error for AuthSessionLayerNotFound {} + +impl IntoResponse for AuthSessionLayerNotFound { + fn into_response(self) -> Response { + (http::status::StatusCode::INTERNAL_SERVER_ERROR, "AuthSessionLayer was not found").into_response() + } + } + +#[async_trait] +impl axum::extract::FromRequestParts for Session { + type Rejection = AuthSessionLayerNotFound; + + async fn from_request_parts(parts: &mut http::request::Parts, state: &S) -> Result { + axum_session_auth::AuthSession::::from_request_parts(parts, state) + .await + .map(Session) + .map_err(|_| AuthSessionLayerNotFound) + } +} diff --git a/packages/fullstack/examples/axum-auth/src/main.rs b/packages/fullstack/examples/axum-auth/src/main.rs new file mode 100644 index 000000000..a3485e2dd --- /dev/null +++ b/packages/fullstack/examples/axum-auth/src/main.rs @@ -0,0 +1,170 @@ +//! Run with: +//! +//! ```sh +//! dioxus build --features web +//! cargo run --features ssr +//! ``` + +#![allow(non_snake_case, unused)] + +#[cfg(feature = "ssr")] +mod auth; + +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; +use serde::{Deserialize, Serialize}; + +fn main() { + #[cfg(feature = "web")] + // Hydrate the application on the client + dioxus_web::launch_cfg(app, dioxus_web::Config::new().hydrate(true)); + + #[cfg(feature = "ssr")] + { + use crate::auth::*; + use axum::routing::*; + use axum_session::SessionConfig; + use axum_session::SessionStore; + use axum_session_auth::AuthConfig; + use axum_session_auth::SessionSqlitePool; + simple_logger::SimpleLogger::new().init().unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let pool = connect_to_database().await; + + //This Defaults as normal Cookies. + //To enable Private cookies for integrity, and authenticity please check the next Example. + let session_config = SessionConfig::default().with_table_name("test_table"); + let auth_config = AuthConfig::::default().with_anonymous_user_id(Some(1)); + let session_store = SessionStore::::new( + Some(pool.clone().into()), + session_config, + ) + .await + .unwrap(); + + //Create the Database table for storing our Session Data. + session_store.initiate().await.unwrap(); + User::create_user_tables(&pool).await; + + // build our application with some routes + let app = Router::new() + // Server side render the application, serve static assets, and register server functions + .serve_dioxus_application("", ServeConfigBuilder::new(app, ())) + .layer( + axum_session_auth::AuthSessionLayer::< + crate::auth::User, + i64, + axum_session_auth::SessionSqlitePool, + sqlx::SqlitePool, + >::new(Some(pool)) + .with_config(auth_config), + ) + .layer(axum_session::SessionLayer::new(session_store)) + ; + + // run it + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000)); + + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap(); + }); + } +} + +fn app(cx: Scope) -> Element { + let user_name = use_state(cx, || "?".to_string()); + let permissions = use_state(cx, || "?".to_string()); + + cx.render(rsx! { + div { + button { + onclick: move |_| { + async move { + login().await.unwrap(); + } + }, + "Login Test User" + } + } + div { + button { + onclick: move |_| { + to_owned![user_name]; + async move { + if let Ok(data) = get_user_name().await { + user_name.set(data); + } + } + }, + "Get User Name" + } + "User name: {user_name}" + } + div { + button { + onclick: move |_| { + to_owned![permissions]; + async move { + if let Ok(data) = get_permissions().await { + permissions.set(data); + } + } + }, + "Get Permissions" + } + "Permissions: {permissions}" + } + }) +} + +#[server(GetUserName)] +pub async fn get_user_name( + #[extract] + session : crate::auth::Session +) -> Result { + Ok(session.0.current_user.unwrap().username + .to_string()) +} + +#[server(Login)] +pub async fn login( + #[extract] + auth: crate::auth::Session +) -> Result<(), ServerFnError> { + auth.login_user(2); + Ok(()) +} + +#[server(Permissions)] +pub async fn get_permissions( + #[extract] + method: axum::http::Method, + #[extract] + auth: crate::auth::Session, +) -> Result { + let current_user = auth.current_user.clone().unwrap_or_default(); + + // lets check permissions only and not worry about if they are anon or not + if !axum_session_auth::Auth::::build([axum::http::Method::GET], false) + .requires(axum_session_auth::Rights::any([ + axum_session_auth::Rights::permission("Category::View"), + axum_session_auth::Rights::permission("Admin::View"), + ])) + .validate(¤t_user, &method, None) + .await + { + return Ok(format!( + "User {}, Does not have permissions needed to view this page please login", + current_user.username + )); + } + + Ok(format!( + "User has Permissions needed. Here are the Users permissions: {:?}", + current_user.permissions + )) +} diff --git a/packages/fullstack/src/props_html/deserialize_props.rs b/packages/fullstack/src/props_html/deserialize_props.rs index b9e2a2aa9..f5c805865 100644 --- a/packages/fullstack/src/props_html/deserialize_props.rs +++ b/packages/fullstack/src/props_html/deserialize_props.rs @@ -26,6 +26,6 @@ pub fn get_root_props_from_document() -> Option { .get_element_by_id("dioxus-storage")? .get_attribute("data-serialized")?; - serde_from_string(&attribute) + serde_from_bytes(attribute.as_bytes()) } }