diff --git a/Cargo.lock b/Cargo.lock index 2d65272ff..9e6bdb3fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7408,27 +7408,6 @@ dependencies = [ "tower-http", ] -[[package]] -name = "fullstack-auth-example-with-fullstack" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "axum 0.8.4", - "axum_session", - "axum_session_auth", - "axum_session_sqlx", - "dioxus", - "dioxus-web", - "execute", - "http 1.3.1", - "serde", - "sqlx", - "tokio", - "tower 0.5.2", - "tower-http", -] - [[package]] name = "fullstack-desktop-example" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 16408ef96..359909381 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,7 +110,6 @@ members = [ "examples/07-fullstack/router", "examples/07-fullstack/desktop", "examples/07-fullstack/auth", - "examples/07-fullstack/auth-with-fullstack", "examples/07-fullstack/ssr-only", # Integrations diff --git a/examples/07-fullstack/auth-with-fullstack/Cargo.toml b/examples/07-fullstack/auth-with-fullstack/Cargo.toml deleted file mode 100644 index c4fc5b75c..000000000 --- a/examples/07-fullstack/auth-with-fullstack/Cargo.toml +++ /dev/null @@ -1,61 +0,0 @@ -[package] -name = "fullstack-auth-example-with-fullstack" -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 = { features = ["fullstack"], workspace = true } -axum = { workspace = true, optional = true, features = ["macros"]} -tokio = { workspace = true, features = ["full"], optional = true } -tower-http = { workspace = true, features = ["auth"], optional = true } -async-trait = { version = "0.1.88", optional = true } -sqlx = { version = "0.8.6", features = [ - "macros", - "migrate", - "postgres", - "sqlite", - "_unstable-all-types", - "tls-native-tls", - "runtime-tokio", -], optional = true } -http = { workspace = true, optional = true } -tower = { workspace = true, optional = true } - -execute = "0.2.13" -serde = { workspace = true } -anyhow = { workspace = true } - -[dependencies.axum_session] -workspace = true -optional = true - -[dependencies.axum_session_auth] -workspace = true -optional = true - -[dependencies.axum_session_sqlx] -workspace = true -features = ["sqlite"] -optional = true - -[features] -default = ["web", "server"] -server = [ - "dioxus/server", - "dep:axum", - "dep:tokio", - "dep:tower-http", - "dep:async-trait", - "dep:sqlx", - "dep:axum_session", - "dep:axum_session_auth", - "dep:axum_session_sqlx", - "dep:http", - "dep:tower", -] -web = ["dioxus/web", "dep:dioxus-web"] diff --git a/examples/07-fullstack/auth-with-fullstack/src/auth.rs b/examples/07-fullstack/auth-with-fullstack/src/auth.rs deleted file mode 100644 index 5b31733d5..000000000 --- a/examples/07-fullstack/auth-with-fullstack/src/auth.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! The code here is pulled from the `axum-session-auth` crate examples, requiring little to no -//! modification to work with dioxus fullstack. - -use async_trait::async_trait; -use axum_session_auth::*; -use axum_session_sqlx::SessionSqlitePool; -use serde::{Deserialize, Serialize}; -use sqlx::sqlite::SqlitePool; -use std::collections::HashSet; - -pub(crate) type Session = axum_session_auth::AuthSession; -pub(crate) type AuthLayer = - axum_session_auth::AuthSessionLayer; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct User { - pub id: i32, - pub anonymous: bool, - pub username: String, - pub permissions: HashSet, -} - -#[derive(sqlx::FromRow, Clone)] -pub(crate) struct SqlPermissionTokens { - pub token: String, -} - -#[async_trait] -impl Authentication for User { - async fn load_user(userid: i64, pool: Option<&SqlitePool>) -> Result { - let db = pool.unwrap(); - - #[derive(sqlx::FromRow, Clone)] - struct SqlUser { - id: i32, - anonymous: bool, - username: String, - } - - let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE id = $1") - .bind(userid) - .fetch_one(db) - .await - .unwrap(); - - //lets just get all the tokens the user can use, we will only use the full permissions if modifying them. - let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>( - "SELECT token FROM user_permissions WHERE user_id = $1;", - ) - .bind(userid) - .fetch_all(db) - .await - .unwrap(); - - Ok(User { - id: sqluser.id, - anonymous: sqluser.anonymous, - username: sqluser.username, - permissions: sql_user_perms.into_iter().map(|x| x.token).collect(), - }) - } - - 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) - } -} diff --git a/examples/07-fullstack/auth-with-fullstack/src/main.rs b/examples/07-fullstack/auth-with-fullstack/src/main.rs deleted file mode 100644 index 30d473dcb..000000000 --- a/examples/07-fullstack/auth-with-fullstack/src/main.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! This example showcases how to use the `axum-session-auth` crate with Dioxus fullstack. -//! We add the `auth::Session` extractor to our server functions to get access to the current user session. -//! -//! To initialize the axum router, we use `dioxus::serve` to spawn a custom axum server that creates -//! our database, session store, and authentication layer. -//! -//! The `.serve_dioxus_application` method is used to mount our Dioxus app as a fallback service to -//! handle HTML rendering and static assets. -//! -//! We easily share the "permissions" between the server and client by using a `HashSet` -//! which is serialized to/from JSON automatically by the server function system. - -use std::collections::HashSet; - -use dioxus::prelude::*; - -#[cfg(feature = "server")] -mod auth; - -fn main() { - // On the client, we simply launch the app as normal, taking over the main thread - #[cfg(not(feature = "server"))] - dioxus::launch(app); - - // On the server, we can use `dioxus::serve` to create a server that serves our app. - // - // The `serve` function takes a closure that returns a `Future` which resolves to an `axum::Router`. - // - // We return a `Router` such that dioxus sets up logging, hot-reloading, devtools, and wires up the - // IP and PORT environment variables to our server. - #[cfg(feature = "server")] - dioxus::serve(|| async { - use crate::auth::*; - use axum_session::{SessionConfig, SessionLayer, SessionStore}; - use axum_session_auth::AuthConfig; - use axum_session_sqlx::SessionSqlitePool; - use sqlx::{sqlite::SqlitePoolOptions, Executor}; - - // Create an in-memory SQLite database and set up our tables - let db = SqlitePoolOptions::new() - .max_connections(20) - .connect_with("sqlite::memory:".parse()?) - .await?; - - // Create the tables (sessions, users) - db.execute(r#"CREATE TABLE IF NOT EXISTS users ( "id" INTEGER PRIMARY KEY, "anonymous" BOOLEAN NOT NULL, "username" VARCHAR(256) NOT NULL )"#,) - .await?; - db.execute(r#"CREATE TABLE IF NOT EXISTS user_permissions ( "user_id" INTEGER NOT NULL, "token" VARCHAR(256) NOT NULL)"#,) - .await?; - - // Insert in some test data for two users (one anonymous, one normal) - db.execute(r#"INSERT INTO users (id, anonymous, username) SELECT 1, true, 'Guest' ON CONFLICT(id) DO UPDATE SET anonymous = EXCLUDED.anonymous, username = EXCLUDED.username"#,) - .await?; - db.execute(r#"INSERT INTO users (id, anonymous, username) SELECT 2, false, 'Test' ON CONFLICT(id) DO UPDATE SET anonymous = EXCLUDED.anonymous, username = EXCLUDED.username"#,) - .await?; - - // Make sure our test user has the ability to view categories - db.execute(r#"INSERT INTO user_permissions (user_id, token) SELECT 2, 'Category::View'"#) - .await?; - - // Create an axum router that dioxus will attach the app to - Ok(dioxus::server::router(app) - .layer( - AuthLayer::new(Some(db.clone())) - .with_config(AuthConfig::::default().with_anonymous_user_id(Some(1))), - ) - .layer(SessionLayer::new( - SessionStore::::new( - Some(db.into()), - SessionConfig::default().with_table_name("test_table"), - ) - .await?, - ))) - }); -} - -/// The UI for our app - is just a few buttons to call our server functions and display the results. -fn app() -> Element { - let mut login = use_action(login); - let mut user_name = use_action(get_user_name); - let mut permissions = use_action(get_permissions); - let mut logout = use_action(logout); - - let fetch_new = move |_| async move { - user_name.call().await; - permissions.call().await; - }; - - rsx! { - div { - button { - onclick: move |_| async move { - login.call().await; - }, - "Login Test User" - } - button { - onclick: move |_| async move { - logout.call().await; - }, - "Logout" - } - button { - onclick: fetch_new, - "Fetch User Info" - } - - pre { "Logged in: {login.result():?}" } - pre { "User name: {user_name.result():?}" } - pre { "Permissions: {permissions.result():?}" } - } - } -} - -/// We use the `auth::Session` extractor to get access to the current user session. -/// This lets us modify the user session, log in/out, and access the current user. -#[post("/api/user/login", auth: auth::Session)] -pub async fn login() -> Result<()> { - auth.login_user(2); - Ok(()) -} - -/// Just like `login`, but this time we log out the user. -#[post("/api/user/logout", auth: auth::Session)] -pub async fn logout() -> Result<()> { - auth.logout_user(); - Ok(()) -} - -/// We can access the current user via `auth.current_user`. -/// We can have both anonymous user (id 1) and a logged in user (id 2). -/// -/// Logged-in users will have more permissions which we can modify. -#[post("/api/user/name", auth: auth::Session)] -pub async fn get_user_name() -> Result { - Ok(auth.current_user.unwrap().username) -} - -/// Get the current user's permissions, guarding the endpoint with the `Auth` validator. -/// If this returns false, we use the `or_unauthorized` extension to return a 401 error. -#[get("/api/user/permissions", auth: auth::Session)] -pub async fn get_permissions() -> Result> { - use crate::auth::User; - use axum_session_auth::{Auth, Rights}; - - let user = auth.current_user.unwrap(); - - Auth::::build([axum::http::Method::GET], false) - .requires(Rights::any([ - Rights::permission("Category::View"), - Rights::permission("Admin::View"), - ])) - .validate(&user, &axum::http::Method::GET, None) - .await - .or_unauthorized("You do not have permission to view categories")?; - - Ok(user.permissions) -} diff --git a/examples/07-fullstack/auth/Cargo.toml b/examples/07-fullstack/auth/Cargo.toml index fea47b1d6..b2aa61371 100644 --- a/examples/07-fullstack/auth/Cargo.toml +++ b/examples/07-fullstack/auth/Cargo.toml @@ -10,7 +10,7 @@ publish = false [dependencies] dioxus-web = { workspace = true, features = ["hydrate"], optional = true } dioxus = { features = ["fullstack"], workspace = true } -axum = { workspace = true, optional = true } +axum = { workspace = true, optional = true, features = ["macros"]} tokio = { workspace = true, features = ["full"], optional = true } tower-http = { workspace = true, features = ["auth"], optional = true } async-trait = { version = "0.1.88", optional = true } @@ -44,7 +44,7 @@ features = ["sqlite"] optional = true [features] -default = [] +default = ["web", "server"] server = [ "dioxus/server", "dep:axum", diff --git a/examples/07-fullstack/auth/src/auth.rs b/examples/07-fullstack/auth/src/auth.rs index 68b6bd1c1..5b31733d5 100644 --- a/examples/07-fullstack/auth/src/auth.rs +++ b/examples/07-fullstack/auth/src/auth.rs @@ -1,3 +1,6 @@ +//! The code here is pulled from the `axum-session-auth` crate examples, requiring little to no +//! modification to work with dioxus fullstack. + use async_trait::async_trait; use axum_session_auth::*; use axum_session_sqlx::SessionSqlitePool; @@ -5,8 +8,9 @@ use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqlitePool; use std::collections::HashSet; -pub(crate) type Session = - axum_session_auth::AuthSession; +pub(crate) type Session = axum_session_auth::AuthSession; +pub(crate) type AuthLayer = + axum_session_auth::AuthSessionLayer; #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct User { @@ -24,11 +28,36 @@ pub(crate) struct SqlPermissionTokens { #[async_trait] impl Authentication for User { async fn load_user(userid: i64, pool: Option<&SqlitePool>) -> Result { - let pool = pool.unwrap(); + let db = pool.unwrap(); - User::get_user(userid, pool) + #[derive(sqlx::FromRow, Clone)] + struct SqlUser { + id: i32, + anonymous: bool, + username: String, + } + + let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE id = $1") + .bind(userid) + .fetch_one(db) .await - .ok_or_else(|| anyhow::anyhow!("Could not load user")) + .unwrap(); + + //lets just get all the tokens the user can use, we will only use the full permissions if modifying them. + let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>( + "SELECT token FROM user_permissions WHERE user_id = $1;", + ) + .bind(userid) + .fetch_all(db) + .await + .unwrap(); + + Ok(User { + id: sqluser.id, + anonymous: sqluser.anonymous, + username: sqluser.username, + permissions: sql_user_perms.into_iter().map(|x| x.token).collect(), + }) } fn is_authenticated(&self) -> bool { @@ -50,95 +79,3 @@ impl HasPermission for User { self.permissions.contains(perm) } } - -impl User { - pub async fn get_user(id: i64, pool: &SqlitePool) -> Option { - #[derive(sqlx::FromRow, Clone)] - struct SqlUser { - id: i32, - anonymous: bool, - username: String, - } - - 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 modifying 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(User { - id: sqluser.id, - anonymous: sqluser.anonymous, - username: sqluser.username, - permissions: sql_user_perms.into_iter().map(|x| x.token).collect(), - }) - } - - pub async fn create_user_tables(pool: &SqlitePool) -> Result<(), sqlx::Error> { - 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?; - - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS user_permissions ( - "user_id" INTEGER NOT NULL, - "token" VARCHAR(256) NOT NULL - )"#, - ) - .execute(pool) - .await?; - - 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?; - - 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?; - - sqlx::query( - r#" - INSERT INTO user_permissions - (user_id, token) SELECT 2, 'Category::View' - "#, - ) - .execute(pool) - .await?; - - Ok(()) - } -} diff --git a/examples/07-fullstack/auth/src/main.rs b/examples/07-fullstack/auth/src/main.rs index 0ca4ff8f3..30d473dcb 100644 --- a/examples/07-fullstack/auth/src/main.rs +++ b/examples/07-fullstack/auth/src/main.rs @@ -1,129 +1,158 @@ -#[cfg(feature = "server")] -mod auth; +//! This example showcases how to use the `axum-session-auth` crate with Dioxus fullstack. +//! We add the `auth::Session` extractor to our server functions to get access to the current user session. +//! +//! To initialize the axum router, we use `dioxus::serve` to spawn a custom axum server that creates +//! our database, session store, and authentication layer. +//! +//! The `.serve_dioxus_application` method is used to mount our Dioxus app as a fallback service to +//! handle HTML rendering and static assets. +//! +//! We easily share the "permissions" between the server and client by using a `HashSet` +//! which is serialized to/from JSON automatically by the server function system. + +use std::collections::HashSet; use dioxus::prelude::*; +#[cfg(feature = "server")] +mod auth; + fn main() { + // On the client, we simply launch the app as normal, taking over the main thread + #[cfg(not(feature = "server"))] + dioxus::launch(app); + + // On the server, we can use `dioxus::serve` to create a server that serves our app. + // + // The `serve` function takes a closure that returns a `Future` which resolves to an `axum::Router`. + // + // We return a `Router` such that dioxus sets up logging, hot-reloading, devtools, and wires up the + // IP and PORT environment variables to our server. #[cfg(feature = "server")] dioxus::serve(|| async { use crate::auth::*; use axum_session::{SessionConfig, SessionLayer, SessionStore}; use axum_session_auth::AuthConfig; use axum_session_sqlx::SessionSqlitePool; - use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; - use std::str::FromStr; + use sqlx::{sqlite::SqlitePoolOptions, Executor}; - let pool = SqlitePoolOptions::new() - .max_connections(5) - .connect_with(SqliteConnectOptions::from_str("sqlite::memory:").unwrap()) + // Create an in-memory SQLite database and set up our tables + let db = SqlitePoolOptions::new() + .max_connections(20) + .connect_with("sqlite::memory:".parse()?) .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 tables (sessions, users) + db.execute(r#"CREATE TABLE IF NOT EXISTS users ( "id" INTEGER PRIMARY KEY, "anonymous" BOOLEAN NOT NULL, "username" VARCHAR(256) NOT NULL )"#,) + .await?; + db.execute(r#"CREATE TABLE IF NOT EXISTS user_permissions ( "user_id" INTEGER NOT NULL, "token" VARCHAR(256) NOT NULL)"#,) + .await?; - User::create_user_tables(&pool).await?; + // Insert in some test data for two users (one anonymous, one normal) + db.execute(r#"INSERT INTO users (id, anonymous, username) SELECT 1, true, 'Guest' ON CONFLICT(id) DO UPDATE SET anonymous = EXCLUDED.anonymous, username = EXCLUDED.username"#,) + .await?; + db.execute(r#"INSERT INTO users (id, anonymous, username) SELECT 2, false, 'Test' ON CONFLICT(id) DO UPDATE SET anonymous = EXCLUDED.anonymous, username = EXCLUDED.username"#,) + .await?; - // build our application with some routes - Ok( - dioxus::server::router(app) - .layer( - axum_session_auth::AuthSessionLayer::< - User, - i64, - SessionSqlitePool, - sqlx::SqlitePool, - >::new(Some(pool)) - .with_config(auth_config), + // Make sure our test user has the ability to view categories + db.execute(r#"INSERT INTO user_permissions (user_id, token) SELECT 2, 'Category::View'"#) + .await?; + + // Create an axum router that dioxus will attach the app to + Ok(dioxus::server::router(app) + .layer( + AuthLayer::new(Some(db.clone())) + .with_config(AuthConfig::::default().with_anonymous_user_id(Some(1))), + ) + .layer(SessionLayer::new( + SessionStore::::new( + Some(db.into()), + SessionConfig::default().with_table_name("test_table"), ) - .layer(SessionLayer::new(session_store)), - ) + .await?, + ))) }); - - dioxus::launch(app); } +/// The UI for our app - is just a few buttons to call our server functions and display the results. fn app() -> Element { + let mut login = use_action(login); let mut user_name = use_action(get_user_name); let mut permissions = use_action(get_permissions); - let mut login = use_action(login); + let mut logout = use_action(logout); + + let fetch_new = move |_| async move { + user_name.call().await; + permissions.call().await; + }; rsx! { div { button { - onclick: move |_| { - login.call(); + onclick: move |_| async move { + login.call().await; }, "Login Test User" } - } - div { button { - onclick: move |_| { - user_name.call(); + onclick: move |_| async move { + logout.call().await; }, - "Get User Name" + "Logout" } - "User name: {user_name.value().unwrap_or_default()}" - } - div { button { - onclick: move |_| { - permissions.call(); - }, - "Get Permissions" + onclick: fetch_new, + "Fetch User Info" } - "Permissions: {permissions.value().unwrap_or_default()}" + + pre { "Logged in: {login.result():?}" } + pre { "User name: {user_name.result():?}" } + pre { "Permissions: {permissions.result():?}" } } } } -#[get("/api/user/name", auth: auth::Session)] -pub async fn get_user_name() -> Result { - Ok(auth - .current_user - .context("UNAUTHORIZED")? - .username - .to_string()) -} - +/// We use the `auth::Session` extractor to get access to the current user session. +/// This lets us modify the user session, log in/out, and access the current user. #[post("/api/user/login", auth: auth::Session)] pub async fn login() -> Result<()> { auth.login_user(2); Ok(()) } +/// Just like `login`, but this time we log out the user. +#[post("/api/user/logout", auth: auth::Session)] +pub async fn logout() -> Result<()> { + auth.logout_user(); + Ok(()) +} + +/// We can access the current user via `auth.current_user`. +/// We can have both anonymous user (id 1) and a logged in user (id 2). +/// +/// Logged-in users will have more permissions which we can modify. +#[post("/api/user/name", auth: auth::Session)] +pub async fn get_user_name() -> Result { + Ok(auth.current_user.unwrap().username) +} + +/// Get the current user's permissions, guarding the endpoint with the `Auth` validator. +/// If this returns false, we use the `or_unauthorized` extension to return a 401 error. #[get("/api/user/permissions", auth: auth::Session)] -pub async fn get_permissions() -> Result { +pub async fn get_permissions() -> Result> { use crate::auth::User; use axum_session_auth::{Auth, Rights}; - let user = auth.current_user.as_ref().context("UNAUTHORIZED")?; + let user = auth.current_user.unwrap(); - // lets check permissions only and not worry about if they are anon or not - let has_permissions = - Auth::::build([axum::http::Method::POST], false) - .requires(Rights::any([ - Rights::permission("Category::View"), - Rights::permission("Admin::View"), - ])) - .validate(user, &axum::http::Method::GET, None) - .await; + Auth::::build([axum::http::Method::GET], false) + .requires(Rights::any([ + Rights::permission("Category::View"), + Rights::permission("Admin::View"), + ])) + .validate(&user, &axum::http::Method::GET, None) + .await + .or_unauthorized("You do not have permission to view categories")?; - if !has_permissions { - dioxus::core::bail!( - "User {}, Does not have permissions needed to view this page please login", - user.username - ); - } - - Ok(format!( - "User has Permissions needed. {:?}", - user.permissions - )) + Ok(user.permissions) }