diff --git a/Cargo.lock b/Cargo.lock index 6c70fe2..fecdbcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "axum-macros", "base64", "bytes", "futures-util", @@ -249,23 +250,38 @@ dependencies = [ ] [[package]] -name = "axum-login" -version = "0.16.0" +name = "axum-extra" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5260ed0ecc8ace8e7e61a7406672faba598c8a86b8f4742fcdde0ddc979a318f" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" dependencies = [ - "async-trait", "axum", - "form_urlencoded", + "axum-core", + "bytes", + "cookie", + "fastrand", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", "serde", - "subtle", - "thiserror 1.0.69", - "tower-cookies", + "tower 0.5.2", "tower-layer", "tower-service", - "tower-sessions", - "tracing", - "urlencoding", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", ] [[package]] @@ -724,7 +740,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", - "serde", ] [[package]] @@ -1841,7 +1856,6 @@ checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", - "serde", ] [[package]] @@ -2533,28 +2547,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rmp" -version = "0.8.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" -dependencies = [ - "byteorder", - "num-traits", - "paste", -] - -[[package]] -name = "rmp-serde" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" -dependencies = [ - "byteorder", - "rmp", - "serde", -] - [[package]] name = "rpassword" version = "7.3.1" @@ -2962,9 +2954,8 @@ name = "sparse-server" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", "axum", - "axum-login", + "axum-extra", "axum-server", "cfg-if", "chrono", @@ -2986,13 +2977,10 @@ dependencies = [ "sqlx", "structopt", "thiserror 1.0.69", - "time", "tokio", "tokio-stream", "tower 0.4.13", "tower-http 0.5.2", - "tower-sessions", - "tower-sessions-sqlx-store", "tracing", "tracing-subscriber", "wasm-bindgen", @@ -3067,7 +3055,6 @@ dependencies = [ "sha2", "smallvec", "thiserror 2.0.11", - "time", "tokio", "tokio-stream", "tracing", @@ -3152,7 +3139,6 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.11", - "time", "tracing", "whoami", ] @@ -3191,7 +3177,6 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.11", - "time", "tracing", "whoami", ] @@ -3216,7 +3201,6 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "time", "tracing", "url", ] @@ -3640,23 +3624,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-cookies" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" -dependencies = [ - "async-trait", - "axum-core", - "cookie", - "futures-util", - "http", - "parking_lot", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-http" version = "0.5.2" @@ -3721,71 +3688,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" -[[package]] -name = "tower-sessions" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65856c81ee244e0f8a55ab0f7b769b72fbde387c235f0a73cd97c579818d05eb" -dependencies = [ - "async-trait", - "http", - "time", - "tokio", - "tower-cookies", - "tower-layer", - "tower-service", - "tower-sessions-core", - "tower-sessions-memory-store", - "tracing", -] - -[[package]] -name = "tower-sessions-core" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb6abbfcaf6436ec5a772cd9f965401da12db793e404ae6134eac066fa5a04f3" -dependencies = [ - "async-trait", - "axum-core", - "base64", - "futures", - "http", - "parking_lot", - "rand", - "serde", - "serde_json", - "thiserror 1.0.69", - "time", - "tokio", - "tracing", -] - -[[package]] -name = "tower-sessions-memory-store" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fad75660c8afbe74f4e7cbbe8e9090171a056b57370ea4d7d5e9eb3e4af3092" -dependencies = [ - "async-trait", - "time", - "tokio", - "tower-sessions-core", -] - -[[package]] -name = "tower-sessions-sqlx-store" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd38eba51214e99accab78f6b7c8e273e90a9cb57575e86b592c60074e182d7" -dependencies = [ - "async-trait", - "rmp-serde", - "sqlx", - "thiserror 1.0.69", - "time", - "tower-sessions-core", -] - [[package]] name = "tracing" version = "0.1.41" @@ -3973,12 +3875,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" diff --git a/flake.nix b/flake.nix index c1b79cf..c4162ae 100644 --- a/flake.nix +++ b/flake.nix @@ -87,6 +87,11 @@ rust-analyzer zls + # Debuggers + gdb + pwndbg + gdbgui + # Cargo lint tools taplo cargo-deny diff --git a/sparse-server/Cargo.toml b/sparse-server/Cargo.toml index f1950c6..0be0c2b 100644 --- a/sparse-server/Cargo.toml +++ b/sparse-server/Cargo.toml @@ -9,13 +9,14 @@ crate-type = ["cdylib", "rlib"] [dependencies] leptos = { version = "^0.7", features = ["nightly"] } leptos_router = { version = "^0.7", features = ["nightly"] } -axum = { version = "^0.7", features = ["ws"], optional = true } +axum = { version = "^0.7", features = ["ws", "macros"], optional = true } +axum-extra = { version = "^0.9", features = ["cookie"], optional = true } console_error_panic_hook = "0.1" leptos_axum = { version = "^0.7", optional = true } leptos_meta = { version = "^0.7" } tokio = { version = "1", features = ["rt-multi-thread", "signal"], optional = true } tower = { version = "0.4", optional = true } -tower-http = { version = "0.5", features = ["fs", "compression-br", "compression-deflate", "compression-gzip", "compression-zstd"], optional = true } +tower-http = { version = "0.5", features = ["fs", "compression-br", "compression-deflate", "compression-gzip", "compression-zstd", "trace"], optional = true } wasm-bindgen = "0.2" thiserror = "1" http = "1" @@ -33,28 +34,20 @@ codee = "0.2" sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "sqlx-sqlite"], optional = true } chrono = { version = "0.4", features = ["serde"] } rpassword = { version = "7.3", optional = true } -pbkdf2 = { version = "0.12", features = ["simple", "sha2"], optional = true } +pbkdf2 = { version = "0.12", features = ["simple", "sha2", "std"], optional = true } sha2 = { version = "0.10", optional = true } hex = { version = "0.4", optional = true } serde = "1.0" -axum-login = { version = "0.16.0", optional = true } -async-trait = "0.1.85" cfg-if = "1.0.0" -tower-sessions = { version = "0.13.0", optional = true } -tower-sessions-sqlx-store = { version = "0.14.0", features = ["sqlite"], optional = true } -time = { version = "0.3.37", optional = true } [features] hydrate = ["leptos/hydrate", "chrono/wasmbind"] ssr = [ "dep:axum", - "dep:axum-login", + "dep:axum-extra", "dep:tokio", - "dep:time", "dep:tower", "dep:tower-http", - "dep:tower-sessions", - "dep:tower-sessions-sqlx-store", "dep:leptos_axum", "dep:axum-server", "dep:tracing-subscriber", diff --git a/sparse-server/migrations/20250129045406_readd_sessions.sql b/sparse-server/migrations/20250129045406_readd_sessions.sql new file mode 100644 index 0000000..3cacce5 --- /dev/null +++ b/sparse-server/migrations/20250129045406_readd_sessions.sql @@ -0,0 +1,8 @@ +CREATE TABLE sessions ( + session_id char(64) NOT NULL, + user_id int NOT NULL, + expires int NOT NULL, + + PRIMARY KEY (session_id), + FOREIGN KEY (user_id) REFERENCES users +); diff --git a/sparse-server/src/app.rs b/sparse-server/src/app.rs index ddcdc74..460a589 100644 --- a/sparse-server/src/app.rs +++ b/sparse-server/src/app.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; use leptos_router::{ components::{A, Route, Router, Routes}, + hooks::use_query_map, path }; use serde::{Serialize, Deserialize}; @@ -21,56 +22,38 @@ pub async fn test_retrieve() -> Result { Ok(since_the_epoch) } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct User { user_id: i64, user_name: String, } #[server] -async fn me() -> Result, ServerFnError> { - let session: crate::db::user::AuthSession = leptos_axum::extract().await?; +pub async fn me() -> Result, ServerFnError> { + tracing::info!("I'm being checked!"); - Ok(session.user.map(|user| User { + let user = crate::db::user::get_auth_session().await?; + + tracing::debug!("User returned: {:?}", user); + + Ok(user.map(|user| User { user_id: user.user_id, user_name: user.user_name })) } #[server] -async fn login(username: String, password: String, next: Option) -> Result<(), ServerFnError> { - use leptos::server_fn::error::NoCustomError; +async fn login(username: String, password: String, next: String) -> Result<(), ServerFnError> { + crate::db::user::create_auth_session(username, password).await?; - let mut session: crate::db::user::AuthSession = leptos_axum::extract().await?; - - let user = match session.authenticate((username, password).clone()).await { - Ok(Some(user)) => user, - Ok(None) => return Err(ServerFnError::::ServerError("Invalid credentials".to_string())), - Err(e) => return Err(server_fn::server_fn_error!(e).into()) - }; - - if let Err(e) = session.login(&user).await { - return Err(server_fn::server_fn_error!(e).into()); - } - - if let Some(target) = next { - leptos_axum::redirect(&target); - } + leptos_axum::redirect(&next); Ok(()) } #[server] async fn logout() -> Result<(), ServerFnError> { - let mut session: crate::db::user::AuthSession = leptos_axum::extract().await?; - - match session.logout().await { - Ok(_) => { - leptos_axum::redirect("/login"); - Ok(()) - } - Err(e) => Err(server_fn::server_fn_error!(e).into()) - } + crate::db::user::destroy_auth_session().await } pub fn shell(options: LeptosOptions) -> impl IntoView { @@ -95,12 +78,38 @@ pub fn shell(options: LeptosOptions) -> impl IntoView { pub fn App() -> impl IntoView { provide_meta_context(); - let user = Resource::new(|| (), |_| async { me().await }); + let login = ServerAction::::new(); + + let (user_res, set_user_res) = signal(None); + + let user = Resource::new( + move || { + login.version().get() + }, + |_| async { + #[cfg(feature = "ssr")] + tracing::info!("Checking user account"); + #[cfg(feature = "hydrate")] + leptos::logging::log!("Checking user account"); + + me().await + } + ); + + Effect::new(move || { + let u = user.get(); + set_user_res(u.map(|u2| u2.ok()).flatten().flatten()); + }); + + provide_context(user_res); + + Effect::new(move || { + leptos::logging::log!("User resource: {:?}", user.get()); + }); view! { - // sets the document title <Router> @@ -108,35 +117,35 @@ pub fn App() -> impl IntoView { <h1>"Sparse control"</h1> <A href="/">"Home"</A> <Suspense fallback=|| ()> - <A href="/beacons">"Beacon management"</A> - <A href="/users">"Users"</A> - {move || user - .get() - .map(|err| err.ok()) - .flatten() - .flatten() - .map(|_| view! { - <a - href="#" - on:click=move |_| { - leptos::task::spawn_local(async move { - let _ = logout().await; - user.refetch(); - }); - } - > - "Log out" - </a> - })} - {move || user - .get() - .map(|err| err.ok()) - .flatten() - .flatten() - .is_none() - .then(|| view! { - <A href="/login">"Log in"</A> - })} + {move || user + .get() + .map(|err| err.ok()) + .flatten() + .flatten() + .map(|_| view! { + <A href="/beacons">"Beacon management"</A> + <A href="/users">"Users"</A> + <a + href="#" + on:click=move |_| { + leptos::task::spawn_local(async move { + let _ = logout().await; + user.refetch(); + }); + } + > + "Log out" + </a> + })} + {move || user + .get() + .map(|err| err.ok()) + .flatten() + .flatten() + .is_none() + .then(|| view! { + <A href="/login">"Log in"</A> + })} </Suspense> </nav> @@ -145,7 +154,7 @@ pub fn App() -> impl IntoView { <Routes fallback=|| "Page not found.".into_view()> <Route path=path!("users") view=crate::users::UserView /> - <Route path=path!("login") view=move || view! { <LoginPage /> } /> + <Route path=path!("login") view=move || view! { <LoginPage login/> }/> <Route path=path!("") view=HomePage/> </Routes> </Router> @@ -153,98 +162,40 @@ pub fn App() -> impl IntoView { } #[component] -fn LoginPage() -> impl IntoView { +fn LoginPage(login: ServerAction<Login>) -> impl IntoView { + let next = move || use_query_map().read().get("next").unwrap_or("/".to_string()); + view! { + <main class="login"> + {move || match login.value().get() { + Some(Ok(_)) => None, + None => None, + Some(Err(e)) => Some(view! { + <div> + "Error signing in: " + {format!("{e:?}")} + </div> + }) + }} + <ActionForm action=login> + <label>"Username"</label> + <input type="text" name="username" /> + <label>"Password"</label> + <input type="password" name="password" /> + <div></div> + <input type="submit" value="Submit" /> + <input type="hidden" value=next name="next" /> + </ActionForm> + </main> + } } /// Renders the home page of your application. #[component] fn HomePage() -> impl IntoView { - - // Creates a reactive value to update the button - let count = RwSignal::new(0); - let on_click = move |_| *count.write() += 1; - - let loaded_time = Resource::new(|| (), |_| async move { test_retrieve().await }); - - let (requested_time, set_requested_time) = signal(None::<String>); - let request_time = Action::new(move |_: &()| async move { - let t = match test_retrieve().await { - Ok(t) => format!("{}", t), - Err(_) => "Error!".to_string() - }; - set_requested_time(Some(t)); - }); - let request_time_callback = move |_| { - request_time.dispatch(()); - }; - let pending = request_time.pending(); - - let text_input = RwSignal::new("".to_owned()); - #[cfg_attr(feature = "ssr", allow(unused_variables))] - let (messages, set_messages) = signal(Vec::<String>::new()); - cfg_if::cfg_if! { - if #[cfg(feature = "hydrate")] { - use leptos_use::{UseWebSocketReturn, use_websocket}; - - let UseWebSocketReturn { send, message, .. } = use_websocket::<String, String, codee::string::FromToStringCodec>("/ws"); - - Effect::new(move |_| { - message.with(move |message| { - if let Some(m) = message { - leptos::logging::log!("got update: {}", m); - set_messages.update(|messages: &mut Vec<_>| messages.push(format!("msg: {}", m))); - } - }) - }); - - let send_message = move |_| { - send(&text_input.get()); - text_input.set("".to_string()); - }; - } else { - let send_message = move |_| {}; - } - } - view! { <main class="main"> - <h1>"Welcome to Leptos!"</h1> - <button on:click=on_click>"Click Me: " {count}</button> - <Suspense - fallback=move || view! { <p>"Loading..."</p> } - > - <h2>"Loaded time:"</h2> - {move || { - loaded_time.get() - .map(|time| match time { - Ok(t) => view! { <p>{format!("{}", t)}</p>}, - Err(_) => view! { <p>{"Error!".to_string()}</p> } - }) - }} - <h2>"Requested time:"</h2> - <button on:click=request_time_callback>"Request new time"</button> - {move || pending.get().then_some("Loading...")} - {move || match requested_time.get() { - Some(t) => { - leptos::logging::log!("updating time display"); - view! { <p>{t}</p> } - }, - None => view! { <p>{"N/A".to_string()}</p> } - }} - </Suspense> - <h2>"Messages"</h2> - <input bind:value=text_input /> - <input - on:click=send_message - type="button" - value="Send message" - /> - {move || messages - .get() - .iter() - .map(|message| view! { <p>{message.clone()}</p> }) - .collect::<Vec<_>>()} + <h1>"Welcome to sparse!"</h1> </main> } } diff --git a/sparse-server/src/db/user.rs b/sparse-server/src/db/user.rs index a0fe834..ce3aa3e 100644 --- a/sparse-server/src/db/user.rs +++ b/sparse-server/src/db/user.rs @@ -1,3 +1,11 @@ +use leptos::{prelude::expect_context, server_fn::error::NoCustomError}; +use leptos_axum::{extract, ResponseOptions}; +use leptos::prelude::ServerFnError; +use pbkdf2::{Pbkdf2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, rand_core::{OsRng, RngCore}, SaltString}}; +use sqlx::SqlitePool; + +use crate::error::Error; + #[derive(Clone)] pub struct User { pub user_id: i64, @@ -6,13 +14,6 @@ pub struct User { pub last_active: Option<i64> } -use async_trait::async_trait; -use pbkdf2::{Pbkdf2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, rand_core::OsRng, SaltString}}; -use axum_login::{AuthUser, AuthnBackend, UserId}; -use sqlx::SqlitePool; - -use crate::error::Error; - impl std::fmt::Debug for User { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("User") @@ -23,27 +24,16 @@ impl std::fmt::Debug for User { } } -impl AuthUser for User { - type Id = i64; - - fn id(&self) -> Self::Id { - self.user_id - } - - fn session_auth_hash(&self) -> &[u8] { - self.password_hash.as_bytes() - } -} - async fn hash_password(pass: &[u8]) -> Result<String, Error> { Ok(tokio::task::spawn_blocking({ let pass = pass.to_owned(); let salt = SaltString::generate(&mut OsRng); - move || Pbkdf2.hash_password( - &*pass, - &salt, - ).map(|hash| hash.to_string()) + move || + Pbkdf2.hash_password( + &*pass, + &salt, + ).map(|hash| hash.serialize().as_str().to_string()) }).await??) } @@ -114,67 +104,149 @@ where Ok(()) } -#[derive(Clone)] -pub struct Backend(SqlitePool); +const SESSION_ID_KEY: &'static str = "session_id"; +const SESSION_AGE: i64 = 30 * 60; -impl Backend { - pub fn new(db: SqlitePool) -> Self { - Self(db) - } -} +pub async fn create_auth_session(username: String, password: String) -> Result<(), ServerFnError> { + use axum_extra::extract::cookie::{Cookie, SameSite}; + use axum::http::{header, HeaderValue}; -#[async_trait] -impl AuthnBackend for Backend { - type User = User; - type Credentials = (String, String); - type Error = Error; + let db = expect_context::<SqlitePool>(); + let resp = expect_context::<ResponseOptions>(); - async fn authenticate( - &self, - creds: Self::Credentials - ) -> Result<Option<Self::User>, Self::Error> { - let user: Option<Self::User> = sqlx::query_as!( - User, - "SELECT * FROM users WHERE user_name = ?", - creds.0 + let user: Option<User> = sqlx::query_as!( + User, + "SELECT * FROM users WHERE user_name = ?", + username + ) + .fetch_optional(&db) + .await?; + + let Some(user) = user else { + return Err(ServerFnError::<NoCustomError>::ServerError("Invalid credentials".to_string())); + }; + + let good_hash = verify_password( + &password, + &user.password_hash + ).await?; + + if good_hash { + let now = chrono::Utc::now().timestamp(); + let expires = now + SESSION_AGE; + + sqlx::query!( + "UPDATE users SET last_active = ?", + now ) - .fetch_optional(&self.0) + .execute(&db) .await?; - let Some(user) = user else { return Ok(None); }; + let session_id: String = tokio::task::spawn_blocking(|| { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + hex::encode(&key[..]) + }).await?; - let good_hash = verify_password( - &user.password_hash, - &creds.1 - ).await?; + sqlx::query!( + "INSERT INTO sessions (session_id, user_id, expires) VALUES (?, ?, ?)", + session_id, + user.user_id, + expires + ) + .execute(&db) + .await?; - if good_hash { - let now = chrono::Utc::now().timestamp(); + let cookie = Cookie::build((SESSION_ID_KEY, &session_id)) + .http_only(true) + .path("/") + .same_site(SameSite::Lax); - sqlx::query!( - "UPDATE users SET last_active = ?", - now - ) - .execute(&self.0) - .await?; - - Ok(Some(user)) - } else { - Ok(None) + if let Ok(cookie) = HeaderValue::from_str(&cookie.to_string()) { + resp.insert_header(header::SET_COOKIE, cookie); } - } - async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> { - let user: Option<Self::User> = sqlx::query_as!( - User, - "SELECT * FROM users WHERE user_id = ?", - user_id - ) - .fetch_optional(&self.0) - .await?; - - Ok(user) + Ok(()) + } else { + Err(ServerFnError::<NoCustomError>::ServerError("Invalid credentials".to_string())) } } -pub type AuthSession = axum_login::AuthSession<Backend>; +pub async fn destroy_auth_session() -> Result<(), ServerFnError> { + use axum_extra::extract::cookie::CookieJar; + + let db = expect_context::<SqlitePool>(); + let jar = extract::<CookieJar>().await?; + + let Some(cookie) = jar.get(SESSION_ID_KEY) else { + return Ok(()); + }; + + let session_id = cookie.value(); + + sqlx::query!( + "DELETE FROM sessions WHERE session_id = ?", + session_id + ) + .execute(&db) + .await?; + + Ok(()) +} + +pub async fn get_auth_session() -> Result<Option<User>, ServerFnError> { + use axum_extra::extract::cookie::CookieJar; + + let db = expect_context::<SqlitePool>(); + let jar = extract::<CookieJar>().await?; + + let Some(cookie) = jar.get(SESSION_ID_KEY) else { + return Ok(None); + }; + + let now = chrono::Utc::now().timestamp(); + let session_id = cookie.value(); + + let user = sqlx::query_as!( + User, + "SELECT users.user_id, user_name, password_hash, last_active \ + FROM users \ + INNER JOIN sessions \ + WHERE session_id = ? \ + AND expires > ?", + session_id, + now + ) + .fetch_optional(&db) + .await?; + + if let Some(u) = &user { + let now = chrono::Utc::now().timestamp(); + let expires = now + SESSION_AGE; + + sqlx::query!( + "UPDATE users SET last_active = ? WHERE user_id = ?", + now, + u.user_id + ) + .execute(&db) + .await?; + + sqlx::query!( + "UPDATE sessions SET expires = ? WHERE session_id = ?", + expires, + session_id + ) + .execute(&db) + .await?; + } + + sqlx::query!( + "DELETE FROM sessions WHERE expires < ?", + now + ) + .execute(&db) + .await?; + + Ok(user) +} diff --git a/sparse-server/src/main.rs b/sparse-server/src/main.rs index 078aad3..10a9fc0 100644 --- a/sparse-server/src/main.rs +++ b/sparse-server/src/main.rs @@ -38,7 +38,7 @@ async fn main() -> anyhow::Result<std::process::ExitCode> { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| format!("{}=debug", env!("CARGO_CRATE_NAME")).into()), + .unwrap_or_else(|_| format!("{}=debug,tower_http=trace", env!("CARGO_CRATE_NAME")).into()), ) .with(tracing_subscriber::fmt::layer()) .init(); diff --git a/sparse-server/src/users.rs b/sparse-server/src/users.rs index 35ff0a9..b081d0a 100644 --- a/sparse-server/src/users.rs +++ b/sparse-server/src/users.rs @@ -3,7 +3,9 @@ use leptos::prelude::*; use serde::{Serialize, Deserialize}; #[cfg(feature = "ssr")] use { - sqlx::SqlitePool + sqlx::SqlitePool, + leptos::server_fn::error::NoCustomError, + crate::db::user }; fn format_delta(time: chrono::TimeDelta) -> String { @@ -42,6 +44,12 @@ pub struct PubUser { #[server] async fn delete_user(user_id: i64) -> Result<(), ServerFnError> { + let user = user::get_auth_session().await?; + + if user.is_none() { + return Err(ServerFnError::<NoCustomError>::ServerError("You are not signed in!".to_owned())); + } + let pool = expect_context::<SqlitePool>(); sqlx::query!( @@ -56,6 +64,12 @@ async fn delete_user(user_id: i64) -> Result<(), ServerFnError> { #[server] async fn reset_password(user_id: i64, password: String) -> Result<(), ServerFnError> { + let user = user::get_auth_session().await?; + + if user.is_none() { + return Err(ServerFnError::<NoCustomError>::ServerError("You are not signed in!".to_owned())); + } + let pool = expect_context::<SqlitePool>(); crate::db::user::reset_password(&pool, user_id as i16, password).await?; @@ -67,7 +81,9 @@ async fn reset_password(user_id: i64, password: String) -> Result<(), ServerFnEr pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl IntoView { use leptos_use::{use_interval, UseIntervalReturn}; + #[cfg_attr(feature = "ssr", allow(unused_variables))] let UseIntervalReturn { counter, .. } = use_interval(1000); + #[cfg_attr(feature = "ssr", allow(unused_variables))] let (time_ago, set_time_ago) = signal(user.last_active.map(|active| format_delta(Utc::now() - active))); #[cfg(feature = "hydrate")] @@ -126,7 +142,7 @@ pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl Into {user.user_name} {move || time_ago.get().map(|active| view! { <span> - "(last activity: " + " (last activity: " {active} ")" </span> @@ -189,6 +205,12 @@ pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl Into #[server] async fn list_users() -> Result<Vec<PubUser>, ServerFnError> { + let user = user::get_auth_session().await?; + + if user.is_none() { + return Err(ServerFnError::<NoCustomError>::ServerError("You are not signed in!".to_owned())); + } + use futures::stream::StreamExt; let pool = expect_context::<SqlitePool>(); @@ -213,6 +235,12 @@ async fn list_users() -> Result<Vec<PubUser>, ServerFnError> { #[server] async fn add_user(name: String, password: String) -> Result<(), ServerFnError> { + let user = user::get_auth_session().await?; + + if user.is_none() { + return Err(ServerFnError::<NoCustomError>::ServerError("You are not signed in!".to_owned())); + } + let pool = expect_context::<SqlitePool>(); crate::db::user::create_user(&pool, name, password).await?; @@ -222,6 +250,15 @@ async fn add_user(name: String, password: String) -> Result<(), ServerFnError> { #[component] pub fn UserView() -> impl IntoView { + #[cfg(feature = "hydrate")] + Effect::new(move || { + let user = expect_context::<ReadSignal<Option<crate::app::User>>>(); + if user.get().is_none() { + let navigate = leptos_router::hooks::use_navigate(); + navigate("/login?next=/users", Default::default()); + } + }); + let user_list = Resource::new(|| (), |_| async move { list_users().await }); let modal_ref = NodeRef::<leptos::html::Dialog>::new(); diff --git a/sparse-server/src/webserver.rs b/sparse-server/src/webserver.rs index 87c0bdf..278b3e6 100644 --- a/sparse-server/src/webserver.rs +++ b/sparse-server/src/webserver.rs @@ -4,68 +4,15 @@ use sqlx::sqlite::SqlitePool; use axum::Router; use leptos::prelude::*; use leptos_axum::{generate_route_list, LeptosRoutes}; -use tokio::{signal, task::AbortHandle}; -use tower_sessions::{Expiry, SessionManagerLayer, session_store::ExpiredDeletion}; -use tower_sessions_sqlx_store::SqliteStore; +use tokio::signal; use sparse_server::app::*; -pub async fn websocket(ws: axum::extract::ws::WebSocketUpgrade) -> axum::response::Response { - tracing::info!("Handling websocket request to /ws"); - ws.on_upgrade(handle_websocket) -} - -async fn handle_websocket(mut socket: axum::extract::ws::WebSocket) { - use tracing::info; - - let mut count = 0; - - loop { - tokio::select! { - msg = socket.recv() => { - match msg { - None => { - break; - } - Some(msg) => { - let Ok(axum::extract::ws::Message::Text(msg)) = msg else { continue; }; - - info!("Received message! {}", msg); - - let Ok(_) = socket.send(axum::extract::ws::Message::Text(msg)).await else { break; }; - } - } - } - _ = tokio::time::sleep(tokio::time::Duration::from_secs(1)) => { - let Ok(_) = socket.send(axum::extract::ws::Message::Text(format!("{}", count))).await else { break; }; - count += 1; - } - } - } -} - pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAddrV4, db: SqlitePool) -> anyhow::Result<ExitCode> { let conf = get_configuration(None).unwrap(); let leptos_options = conf.leptos_options; let routes = generate_route_list(App); - let session_store = SqliteStore::new(db.clone()); - session_store.migrate().await?; - - let deletion_task = tokio::task::spawn( - session_store - .clone() - .continuously_delete_expired(tokio::time::Duration::from_secs(60)) - ); - - let session_layer = SessionManagerLayer::new(session_store) - .with_secure(false) - .with_expiry(Expiry::OnInactivity(time::Duration::minutes(20))); - - - let backend = crate::db::user::Backend::new(db.clone()); - let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build(); - let compression_layer = tower_http::compression::CompressionLayer::new() .gzip(true) .deflate(true) @@ -73,7 +20,6 @@ pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAd .zstd(true); let app = Router::new() - .route("/ws", axum::routing::any(websocket)) .leptos_routes_with_context( &leptos_options, routes, @@ -81,11 +27,15 @@ pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAd { let leptos_options = leptos_options.clone(); move || shell(leptos_options.clone()) - }) - .fallback(leptos_axum::file_and_error_handler(shell)) + } + ) + .fallback(leptos_axum::file_and_error_handler::<leptos::config::LeptosOptions, _>(shell)) .with_state(leptos_options) - .layer(auth_layer) - .layer(compression_layer); + .layer( + tower::ServiceBuilder::new() + .layer(tower_http::trace::TraceLayer::new_for_http()) + .layer(compression_layer) + ); // run our app with hyper // `axum::Server` is a re-export of `hyper::Server` @@ -93,15 +43,13 @@ pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAd tracing::info!("management interface listening on http://{}", &management_address); axum::serve(management_listener, app.into_make_service()) - .with_graceful_shutdown(shutdown_signal(deletion_task.abort_handle())) + .with_graceful_shutdown(shutdown_signal()) .await?; - deletion_task.await??; - Ok(ExitCode::SUCCESS) } -async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) { +async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c() .await @@ -122,11 +70,9 @@ async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) { tokio::select! { _ = ctrl_c => { tracing::info!("Received Ctrl-C"); - deletion_task_abort_handle.abort() }, _ = terminate => { tracing::info!("Received terminate command"); - deletion_task_abort_handle.abort() }, } }