feat: got sessions working

This commit is contained in:
Andrew Rioux 2025-01-29 18:39:10 -05:00
parent bf879bb081
commit 0d6b2b4c16
Signed by: andrew.rioux
GPG Key ID: 9B8BAC47C17ABB94
9 changed files with 339 additions and 431 deletions

160
Cargo.lock generated
View File

@ -197,6 +197,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum-core", "axum-core",
"axum-macros",
"base64", "base64",
"bytes", "bytes",
"futures-util", "futures-util",
@ -249,23 +250,38 @@ dependencies = [
] ]
[[package]] [[package]]
name = "axum-login" name = "axum-extra"
version = "0.16.0" version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5260ed0ecc8ace8e7e61a7406672faba598c8a86b8f4742fcdde0ddc979a318f" checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
dependencies = [ dependencies = [
"async-trait",
"axum", "axum",
"form_urlencoded", "axum-core",
"bytes",
"cookie",
"fastrand",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"multer",
"pin-project-lite",
"serde", "serde",
"subtle", "tower 0.5.2",
"thiserror 1.0.69",
"tower-cookies",
"tower-layer", "tower-layer",
"tower-service", "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]] [[package]]
@ -724,7 +740,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
"serde",
] ]
[[package]] [[package]]
@ -1841,7 +1856,6 @@ checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"scopeguard", "scopeguard",
"serde",
] ]
[[package]] [[package]]
@ -2533,28 +2547,6 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rpassword" name = "rpassword"
version = "7.3.1" version = "7.3.1"
@ -2962,9 +2954,8 @@ name = "sparse-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"axum", "axum",
"axum-login", "axum-extra",
"axum-server", "axum-server",
"cfg-if", "cfg-if",
"chrono", "chrono",
@ -2986,13 +2977,10 @@ dependencies = [
"sqlx", "sqlx",
"structopt", "structopt",
"thiserror 1.0.69", "thiserror 1.0.69",
"time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tower 0.4.13", "tower 0.4.13",
"tower-http 0.5.2", "tower-http 0.5.2",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"wasm-bindgen", "wasm-bindgen",
@ -3067,7 +3055,6 @@ dependencies = [
"sha2", "sha2",
"smallvec", "smallvec",
"thiserror 2.0.11", "thiserror 2.0.11",
"time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@ -3152,7 +3139,6 @@ dependencies = [
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 2.0.11", "thiserror 2.0.11",
"time",
"tracing", "tracing",
"whoami", "whoami",
] ]
@ -3191,7 +3177,6 @@ dependencies = [
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 2.0.11", "thiserror 2.0.11",
"time",
"tracing", "tracing",
"whoami", "whoami",
] ]
@ -3216,7 +3201,6 @@ dependencies = [
"serde", "serde",
"serde_urlencoded", "serde_urlencoded",
"sqlx-core", "sqlx-core",
"time",
"tracing", "tracing",
"url", "url",
] ]
@ -3640,23 +3624,6 @@ dependencies = [
"tracing", "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]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.5.2" version = "0.5.2"
@ -3721,71 +3688,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 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]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.41" version = "0.1.41"
@ -3973,12 +3875,6 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf-8" name = "utf-8"
version = "0.7.6" version = "0.7.6"

View File

@ -87,6 +87,11 @@
rust-analyzer rust-analyzer
zls zls
# Debuggers
gdb
pwndbg
gdbgui
# Cargo lint tools # Cargo lint tools
taplo taplo
cargo-deny cargo-deny

View File

@ -9,13 +9,14 @@ crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
leptos = { version = "^0.7", features = ["nightly"] } leptos = { version = "^0.7", features = ["nightly"] }
leptos_router = { 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" console_error_panic_hook = "0.1"
leptos_axum = { version = "^0.7", optional = true } leptos_axum = { version = "^0.7", optional = true }
leptos_meta = { version = "^0.7" } leptos_meta = { version = "^0.7" }
tokio = { version = "1", features = ["rt-multi-thread", "signal"], optional = true } tokio = { version = "1", features = ["rt-multi-thread", "signal"], optional = true }
tower = { version = "0.4", 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" wasm-bindgen = "0.2"
thiserror = "1" thiserror = "1"
http = "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 } sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "sqlx-sqlite"], optional = true }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
rpassword = { version = "7.3", optional = true } 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 } sha2 = { version = "0.10", optional = true }
hex = { version = "0.4", optional = true } hex = { version = "0.4", optional = true }
serde = "1.0" serde = "1.0"
axum-login = { version = "0.16.0", optional = true }
async-trait = "0.1.85"
cfg-if = "1.0.0" 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] [features]
hydrate = ["leptos/hydrate", "chrono/wasmbind"] hydrate = ["leptos/hydrate", "chrono/wasmbind"]
ssr = [ ssr = [
"dep:axum", "dep:axum",
"dep:axum-login", "dep:axum-extra",
"dep:tokio", "dep:tokio",
"dep:time",
"dep:tower", "dep:tower",
"dep:tower-http", "dep:tower-http",
"dep:tower-sessions",
"dep:tower-sessions-sqlx-store",
"dep:leptos_axum", "dep:leptos_axum",
"dep:axum-server", "dep:axum-server",
"dep:tracing-subscriber", "dep:tracing-subscriber",

View File

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

View File

@ -2,6 +2,7 @@ use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
use leptos_router::{ use leptos_router::{
components::{A, Route, Router, Routes}, components::{A, Route, Router, Routes},
hooks::use_query_map,
path path
}; };
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
@ -21,56 +22,38 @@ pub async fn test_retrieve() -> Result<u64, ServerFnError> {
Ok(since_the_epoch) Ok(since_the_epoch)
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct User { pub struct User {
user_id: i64, user_id: i64,
user_name: String, user_name: String,
} }
#[server] #[server]
async fn me() -> Result<Option<User>, ServerFnError> { pub async fn me() -> Result<Option<User>, ServerFnError> {
let session: crate::db::user::AuthSession = leptos_axum::extract().await?; 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_id: user.user_id,
user_name: user.user_name user_name: user.user_name
})) }))
} }
#[server] #[server]
async fn login(username: String, password: String, next: Option<String>) -> Result<(), ServerFnError> { async fn login(username: String, password: String, next: String) -> Result<(), ServerFnError> {
use leptos::server_fn::error::NoCustomError; crate::db::user::create_auth_session(username, password).await?;
let mut session: crate::db::user::AuthSession = leptos_axum::extract().await?; leptos_axum::redirect(&next);
let user = match session.authenticate((username, password).clone()).await {
Ok(Some(user)) => user,
Ok(None) => return Err(ServerFnError::<NoCustomError>::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);
}
Ok(()) Ok(())
} }
#[server] #[server]
async fn logout() -> Result<(), ServerFnError> { async fn logout() -> Result<(), ServerFnError> {
let mut session: crate::db::user::AuthSession = leptos_axum::extract().await?; crate::db::user::destroy_auth_session().await
match session.logout().await {
Ok(_) => {
leptos_axum::redirect("/login");
Ok(())
}
Err(e) => Err(server_fn::server_fn_error!(e).into())
}
} }
pub fn shell(options: LeptosOptions) -> impl IntoView { pub fn shell(options: LeptosOptions) -> impl IntoView {
@ -95,12 +78,38 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
provide_meta_context(); provide_meta_context();
let user = Resource::new(|| (), |_| async { me().await }); let login = ServerAction::<Login>::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! { view! {
<Stylesheet id="leptos" href="/pkg/sparse-server.css"/> <Stylesheet id="leptos" href="/pkg/sparse-server.css"/>
// sets the document title
<Title text="Sparse Control"/> <Title text="Sparse Control"/>
<Router> <Router>
@ -108,35 +117,35 @@ pub fn App() -> impl IntoView {
<h1>"Sparse control"</h1> <h1>"Sparse control"</h1>
<A href="/">"Home"</A> <A href="/">"Home"</A>
<Suspense fallback=|| ()> <Suspense fallback=|| ()>
<A href="/beacons">"Beacon management"</A> {move || user
<A href="/users">"Users"</A> .get()
{move || user .map(|err| err.ok())
.get() .flatten()
.map(|err| err.ok()) .flatten()
.flatten() .map(|_| view! {
.flatten() <A href="/beacons">"Beacon management"</A>
.map(|_| view! { <A href="/users">"Users"</A>
<a <a
href="#" href="#"
on:click=move |_| { on:click=move |_| {
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
let _ = logout().await; let _ = logout().await;
user.refetch(); user.refetch();
}); });
} }
> >
"Log out" "Log out"
</a> </a>
})} })}
{move || user {move || user
.get() .get()
.map(|err| err.ok()) .map(|err| err.ok())
.flatten() .flatten()
.flatten() .flatten()
.is_none() .is_none()
.then(|| view! { .then(|| view! {
<A href="/login">"Log in"</A> <A href="/login">"Log in"</A>
})} })}
</Suspense> </Suspense>
</nav> </nav>
@ -145,7 +154,7 @@ pub fn App() -> impl IntoView {
<Routes fallback=|| "Page not found.".into_view()> <Routes fallback=|| "Page not found.".into_view()>
<Route path=path!("users") view=crate::users::UserView /> <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/> <Route path=path!("") view=HomePage/>
</Routes> </Routes>
</Router> </Router>
@ -153,98 +162,40 @@ pub fn App() -> impl IntoView {
} }
#[component] #[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. /// Renders the home page of your application.
#[component] #[component]
fn HomePage() -> impl IntoView { 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! { view! {
<main class="main"> <main class="main">
<h1>"Welcome to Leptos!"</h1> <h1>"Welcome to sparse!"</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<_>>()}
</main> </main>
} }
} }

View File

@ -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)] #[derive(Clone)]
pub struct User { pub struct User {
pub user_id: i64, pub user_id: i64,
@ -6,13 +14,6 @@ pub struct User {
pub last_active: Option<i64> 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 { impl std::fmt::Debug for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User") 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> { async fn hash_password(pass: &[u8]) -> Result<String, Error> {
Ok(tokio::task::spawn_blocking({ Ok(tokio::task::spawn_blocking({
let pass = pass.to_owned(); let pass = pass.to_owned();
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
move || Pbkdf2.hash_password( move ||
&*pass, Pbkdf2.hash_password(
&salt, &*pass,
).map(|hash| hash.to_string()) &salt,
).map(|hash| hash.serialize().as_str().to_string())
}).await??) }).await??)
} }
@ -114,67 +104,149 @@ where
Ok(()) Ok(())
} }
#[derive(Clone)] const SESSION_ID_KEY: &'static str = "session_id";
pub struct Backend(SqlitePool); const SESSION_AGE: i64 = 30 * 60;
impl Backend { pub async fn create_auth_session(username: String, password: String) -> Result<(), ServerFnError> {
pub fn new(db: SqlitePool) -> Self { use axum_extra::extract::cookie::{Cookie, SameSite};
Self(db) use axum::http::{header, HeaderValue};
}
}
#[async_trait] let db = expect_context::<SqlitePool>();
impl AuthnBackend for Backend { let resp = expect_context::<ResponseOptions>();
type User = User;
type Credentials = (String, String);
type Error = Error;
async fn authenticate( let user: Option<User> = sqlx::query_as!(
&self, User,
creds: Self::Credentials "SELECT * FROM users WHERE user_name = ?",
) -> Result<Option<Self::User>, Self::Error> { username
let user: Option<Self::User> = sqlx::query_as!( )
User, .fetch_optional(&db)
"SELECT * FROM users WHERE user_name = ?", .await?;
creds.0
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?; .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( sqlx::query!(
&user.password_hash, "INSERT INTO sessions (session_id, user_id, expires) VALUES (?, ?, ?)",
&creds.1 session_id,
).await?; user.user_id,
expires
)
.execute(&db)
.await?;
if good_hash { let cookie = Cookie::build((SESSION_ID_KEY, &session_id))
let now = chrono::Utc::now().timestamp(); .http_only(true)
.path("/")
.same_site(SameSite::Lax);
sqlx::query!( if let Ok(cookie) = HeaderValue::from_str(&cookie.to_string()) {
"UPDATE users SET last_active = ?", resp.insert_header(header::SET_COOKIE, cookie);
now
)
.execute(&self.0)
.await?;
Ok(Some(user))
} else {
Ok(None)
} }
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> { Ok(())
let user: Option<Self::User> = sqlx::query_as!( } else {
User, Err(ServerFnError::<NoCustomError>::ServerError("Invalid credentials".to_string()))
"SELECT * FROM users WHERE user_id = ?",
user_id
)
.fetch_optional(&self.0)
.await?;
Ok(user)
} }
} }
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)
}

View File

@ -38,7 +38,7 @@ async fn main() -> anyhow::Result<std::process::ExitCode> {
tracing_subscriber::registry() tracing_subscriber::registry()
.with( .with(
tracing_subscriber::EnvFilter::try_from_default_env() 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()) .with(tracing_subscriber::fmt::layer())
.init(); .init();

View File

@ -3,7 +3,9 @@ use leptos::prelude::*;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use { use {
sqlx::SqlitePool sqlx::SqlitePool,
leptos::server_fn::error::NoCustomError,
crate::db::user
}; };
fn format_delta(time: chrono::TimeDelta) -> String { fn format_delta(time: chrono::TimeDelta) -> String {
@ -42,6 +44,12 @@ pub struct PubUser {
#[server] #[server]
async fn delete_user(user_id: i64) -> Result<(), ServerFnError> { 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>(); let pool = expect_context::<SqlitePool>();
sqlx::query!( sqlx::query!(
@ -56,6 +64,12 @@ async fn delete_user(user_id: i64) -> Result<(), ServerFnError> {
#[server] #[server]
async fn reset_password(user_id: i64, password: String) -> Result<(), ServerFnError> { 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>(); let pool = expect_context::<SqlitePool>();
crate::db::user::reset_password(&pool, user_id as i16, password).await?; 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 { pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl IntoView {
use leptos_use::{use_interval, UseIntervalReturn}; use leptos_use::{use_interval, UseIntervalReturn};
#[cfg_attr(feature = "ssr", allow(unused_variables))]
let UseIntervalReturn { counter, .. } = use_interval(1000); 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))); let (time_ago, set_time_ago) = signal(user.last_active.map(|active| format_delta(Utc::now() - active)));
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
@ -126,7 +142,7 @@ pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl Into
{user.user_name} {user.user_name}
{move || time_ago.get().map(|active| view! { {move || time_ago.get().map(|active| view! {
<span> <span>
"(last activity: " " (last activity: "
{active} {active}
")" ")"
</span> </span>
@ -189,6 +205,12 @@ pub fn RenderUser(refresh_user_list: Action<(), ()>, user: PubUser) -> impl Into
#[server] #[server]
async fn list_users() -> Result<Vec<PubUser>, ServerFnError> { 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; use futures::stream::StreamExt;
let pool = expect_context::<SqlitePool>(); let pool = expect_context::<SqlitePool>();
@ -213,6 +235,12 @@ async fn list_users() -> Result<Vec<PubUser>, ServerFnError> {
#[server] #[server]
async fn add_user(name: String, password: String) -> Result<(), ServerFnError> { 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>(); let pool = expect_context::<SqlitePool>();
crate::db::user::create_user(&pool, name, password).await?; crate::db::user::create_user(&pool, name, password).await?;
@ -222,6 +250,15 @@ async fn add_user(name: String, password: String) -> Result<(), ServerFnError> {
#[component] #[component]
pub fn UserView() -> impl IntoView { 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 user_list = Resource::new(|| (), |_| async move { list_users().await });
let modal_ref = NodeRef::<leptos::html::Dialog>::new(); let modal_ref = NodeRef::<leptos::html::Dialog>::new();

View File

@ -4,68 +4,15 @@ use sqlx::sqlite::SqlitePool;
use axum::Router; use axum::Router;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes}; use leptos_axum::{generate_route_list, LeptosRoutes};
use tokio::{signal, task::AbortHandle}; use tokio::signal;
use tower_sessions::{Expiry, SessionManagerLayer, session_store::ExpiredDeletion};
use tower_sessions_sqlx_store::SqliteStore;
use sparse_server::app::*; 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> { pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAddrV4, db: SqlitePool) -> anyhow::Result<ExitCode> {
let conf = get_configuration(None).unwrap(); let conf = get_configuration(None).unwrap();
let leptos_options = conf.leptos_options; let leptos_options = conf.leptos_options;
let routes = generate_route_list(App); 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() let compression_layer = tower_http::compression::CompressionLayer::new()
.gzip(true) .gzip(true)
.deflate(true) .deflate(true)
@ -73,7 +20,6 @@ pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAd
.zstd(true); .zstd(true);
let app = Router::new() let app = Router::new()
.route("/ws", axum::routing::any(websocket))
.leptos_routes_with_context( .leptos_routes_with_context(
&leptos_options, &leptos_options,
routes, routes,
@ -81,11 +27,15 @@ pub async fn serve_web(management_address: SocketAddrV4, _bind_address: SocketAd
{ {
let leptos_options = leptos_options.clone(); let leptos_options = leptos_options.clone();
move || shell(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) .with_state(leptos_options)
.layer(auth_layer) .layer(
.layer(compression_layer); tower::ServiceBuilder::new()
.layer(tower_http::trace::TraceLayer::new_for_http())
.layer(compression_layer)
);
// run our app with hyper // run our app with hyper
// `axum::Server` is a re-export of `hyper::Server` // `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); tracing::info!("management interface listening on http://{}", &management_address);
axum::serve(management_listener, app.into_make_service()) axum::serve(management_listener, app.into_make_service())
.with_graceful_shutdown(shutdown_signal(deletion_task.abort_handle())) .with_graceful_shutdown(shutdown_signal())
.await?; .await?;
deletion_task.await??;
Ok(ExitCode::SUCCESS) Ok(ExitCode::SUCCESS)
} }
async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) { async fn shutdown_signal() {
let ctrl_c = async { let ctrl_c = async {
signal::ctrl_c() signal::ctrl_c()
.await .await
@ -122,11 +70,9 @@ async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) {
tokio::select! { tokio::select! {
_ = ctrl_c => { _ = ctrl_c => {
tracing::info!("Received Ctrl-C"); tracing::info!("Received Ctrl-C");
deletion_task_abort_handle.abort()
}, },
_ = terminate => { _ = terminate => {
tracing::info!("Received terminate command"); tracing::info!("Received terminate command");
deletion_task_abort_handle.abort()
}, },
} }
} }