From 5ca02189f467d79de32ed894dc5b86f4e4158b5e Mon Sep 17 00:00:00 2001 From: xeovalyte Date: Mon, 30 Sep 2024 17:01:13 +0200 Subject: [PATCH] Added basic auth system --- Cargo.lock | 144 +++++++++++++++++-------- Cargo.toml | 6 +- assets/tailwind.css | 20 ++-- src/components/layout.rs | 13 ++- src/components/layout/auth.rs | 164 +++++++++++++++++++++++------ src/err.rs | 23 ++++ src/main.rs | 4 + src/util/model.rs | 1 + src/util/model/member/migration.rs | 4 +- src/util/model/session.rs | 84 +++++++++++++++ src/util/model/user.rs | 62 +++++++++++ src/util/surrealdb.rs | 38 +++++++ 12 files changed, 466 insertions(+), 97 deletions(-) create mode 100644 src/err.rs create mode 100644 src/util/model/session.rs diff --git a/Cargo.lock b/Cargo.lock index 04810a2..c134efb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,14 +362,14 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "axum" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" +checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" dependencies = [ "async-trait", "axum-core", "axum-macros", - "base64 0.21.7", + "base64 0.22.1", "bytes", "futures-util", "http 1.1.0", @@ -392,8 +392,8 @@ dependencies = [ "sha1", "sync_wrapper 1.0.1", "tokio", - "tokio-tungstenite 0.21.0", - "tower", + "tokio-tungstenite 0.24.0", + "tower 0.5.1", "tower-layer", "tower-service", "tracing", @@ -401,9 +401,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", "bytes", @@ -414,7 +414,30 @@ dependencies = [ "mime", "pin-project-lite", "rustversion", - "sync_wrapper 0.1.2", + "sync_wrapper 1.0.1", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower 0.5.1", "tower-layer", "tower-service", "tracing", @@ -422,11 +445,10 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c055ee2d014ae5981ce1016374e8213682aa14d9bf40e48ab48b5f3ef20eaa" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" dependencies = [ - "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.77", @@ -961,6 +983,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1241,7 +1274,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", - "tower", + "tower 0.4.13", "tower-http", "tower-layer", "tracing", @@ -2435,7 +2468,7 @@ dependencies = [ "pin-project-lite", "socket2", "tokio", - "tower", + "tower 0.4.13", "tower-service", "tracing", ] @@ -2722,7 +2755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -4468,7 +4501,7 @@ dependencies = [ "serde_qs", "server_fn_macro_default", "thiserror", - "tower", + "tower 0.4.13", "tower-layer", "url", "wasm-bindgen", @@ -5199,18 +5232,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "tokio-tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.21.0", -] - [[package]] name = "tokio-tungstenite" version = "0.23.1" @@ -5227,6 +5248,18 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + [[package]] name = "tokio-util" version = "0.7.12" @@ -5304,6 +5337,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 0.1.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-http" version = "0.5.2" @@ -5437,25 +5486,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http 1.1.0", - "httparse", - "log", - "rand", - "sha1", - "thiserror", - "url", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.23.0" @@ -5477,6 +5507,24 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.1.0", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + [[package]] name = "typenum" version = "1.17.0" @@ -6022,6 +6070,7 @@ name = "wrbapp" version = "0.1.0" dependencies = [ "axum", + "axum-extra", "csv", "dioxus", "dioxus-logger", @@ -6029,8 +6078,11 @@ dependencies = [ "once_cell", "serde", "surrealdb", + "thiserror", + "time", "tokio", "tracing", + "web-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c96db5e..d31366b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,15 @@ edition = "2021" serde = { version = "1.0.197", features = ["derive"] } dioxus = { version = "0.5", features = ["fullstack", "router"] } +web-sys = { version = "0.3.70", features = ["Window", "Location"] } tokio = { version = "1.38", features = ["macros", "rt-multi-thread"], optional = true } axum = { version = "0.7", optional = true } +axum-extra = { version = "0.9", features = ["cookie"], optional = true } +time = { version = "0.3", optional = true } once_cell = { version = "1.19", optional = true } surrealdb = { version = "2.0", features = ["kv-rocksdb"], optional = true } +thiserror = { version = "1.0" } csv = { version = "1.3", optional = true } @@ -25,5 +29,5 @@ manganis = "0.2" [features] default = [] -server = [ "dioxus/axum", "tokio", "axum", "once_cell", "surrealdb", "csv" ] +server = [ "dioxus/axum", "tokio", "axum", "axum-extra", "time", "once_cell", "surrealdb", "csv" ] web = ["dioxus/web"] diff --git a/assets/tailwind.css b/assets/tailwind.css index 4162fb4..cec965f 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -2404,24 +2404,24 @@ input.tab:checked + .tab-content, margin-bottom: 0.25rem; } -.mb-10 { - margin-bottom: 2.5rem; -} - .mb-5 { margin-bottom: 1.25rem; } +.mb-6 { + margin-bottom: 1.5rem; +} + .mt-10 { margin-top: 2.5rem; } -.mt-2 { - margin-top: 0.5rem; +.mt-16 { + margin-top: 4rem; } -.mt-20 { - margin-top: 5rem; +.mt-3 { + margin-top: 0.75rem; } .mt-5 { @@ -2601,8 +2601,8 @@ input.tab:checked + .tab-content, padding-bottom: 2.5rem; } -.pb-3 { - padding-bottom: 0.75rem; +.pt-2 { + padding-top: 0.5rem; } .text-center { diff --git a/src/components/layout.rs b/src/components/layout.rs index c4d5848..2de2c7d 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -3,7 +3,10 @@ pub mod icons; pub mod navbar; pub mod topbar; -use crate::Route; +use crate::{ + util::model::{session::Session, user::User}, + Route, +}; use auth::Auth; use dioxus::prelude::*; @@ -34,7 +37,9 @@ pub fn Global() -> Element { } #[server] -async fn get_user_from_cookie() -> Result<(), ServerFnError> { - Err(ServerFnError::new("Not authenticated")) - // Ok(()) +async fn get_user_from_cookie() -> Result { + let token = Session::get_token_from_cookie().await?; + let user = Session::fetch_user_from_token(token).await?; + + Ok(user) } diff --git a/src/components/layout/auth.rs b/src/components/layout/auth.rs index 3d284dc..3d84ae2 100644 --- a/src/components/layout/auth.rs +++ b/src/components/layout/auth.rs @@ -2,6 +2,8 @@ use dioxus::prelude::*; use crate::components::layout::icons; use crate::util::model::member::Member; +use crate::util::model::session::Session; +use crate::util::model::user::User; use std::collections::HashMap; pub fn Auth() -> Element { @@ -10,14 +12,14 @@ pub fn Auth() -> Element { rsx! { div { class: "flex flex-col items-center", - h1 { class: "font-bold text-primary text-center text-2xl mt-20", "Waddinxveense Reddingsbrigade" }, + h1 { class: "font-bold text-primary text-center text-2xl mt-16", "Waddinxveense Reddingsbrigade" }, div { - class: "card bg-base-200 mt-20 w-full max-w-lg shadow-xl", + class: "card bg-base-200 mt-8 w-full max-w-lg shadow-xl", div { class: "card-body", div { role: "tablist", - class: "tabs tabs-boxed bg-base-300 mx-auto w-full mb-10", + class: "tabs tabs-boxed bg-base-300 mx-auto w-full mb-6", button { onclick: move |_| register.set(false), role: "tab", @@ -45,36 +47,49 @@ pub fn Auth() -> Element { } fn Login() -> Element { + let mut input_email = use_signal(|| "".to_string()); + let mut input_password = use_signal(|| "".to_string()); + + let submit = move |_| async move { + if let Ok(_) = login(input_email(), input_password()).await { + let window = web_sys::window().expect("Could not find window"); + window.location().reload().expect("Could not reload window"); + } + }; + rsx! { - form { - label { - class: "form-control w-full", - div { - class: "label", - span { class: "label-text", "Email" }, - } - input { - r#type: "email", - class: "input input-bordered w-full", - }, - } - label { - class: "form-control w-full mt-5", - div { - class: "label", - span { class: "label-text", "Password" }, - } - input { - r#type: "password", - class: "input input-bordered w-full", - }, - } + label { + class: "form-control w-full", div { - class: "card-actions mt-8", - button { - class: "btn btn-primary", - "Inloggen" - } + class: "label", + span { class: "label-text", "Email" }, + } + input { + r#type: "email", + class: "input input-bordered w-full", + value: "{input_email}", + oninput: move |event| input_email.set(event.value()) + }, + } + label { + class: "form-control w-full mt-3", + div { + class: "label", + span { class: "label-text", "Wachtwoord" }, + } + input { + r#type: "password", + class: "input input-bordered w-full", + value: "{input_password}", + oninput: move |event| input_password.set(event.value()) + }, + } + div { + class: "card-actions mt-5", + button { + class: "btn btn-primary", + onclick: submit, + "Inloggen", } } } @@ -82,7 +97,20 @@ fn Login() -> Element { fn Register() -> Element { let mut members: Signal> = use_signal(|| HashMap::new()); + let mut input_registration_token = use_signal(|| "".to_string()); + let mut input_email = use_signal(|| "".to_string()); + let mut input_password = use_signal(|| "".to_string()); + let mut input_password_repeat = use_signal(|| "".to_string()); + + let submit = move |_| async move { + let members_set: Vec = members().into_keys().collect(); + + if let Ok(_) = register(input_email(), input_password(), members_set).await { + let window = web_sys::window().expect("Could not find window"); + window.location().reload().expect("Could not reload window"); + } + }; rsx! { label { @@ -112,7 +140,7 @@ fn Register() -> Element { } } div { - class: "flex gap-1 mt-2 flex-wrap pb-3", + class: "flex gap-1 pt-2 flex-wrap", for (id, member) in members() { div { class: "badge badge-md badge-neutral h-8 gap-1 hover:cursor-pointer hover:line-through whitespace-nowrap", @@ -124,17 +152,85 @@ fn Register() -> Element { } } } + label { + class: "form-control w-full mt-3", + div { + class: "label", + span { class: "label-text", "Email" }, + } + input { + r#type: "email", + class: "input input-bordered w-full", + value: "{input_email}", + oninput: move |event| input_email.set(event.value()) + }, + } + label { + class: "form-control w-full mt-3", + div { + class: "label", + span { class: "label-text", "Wachtwoord" }, + } + input { + r#type: "password", + class: "input input-bordered w-full", + value: "{input_password}", + oninput: move |event| input_password.set(event.value()) + }, + } + label { + class: "form-control w-full mt-3", + div { + class: "label", + span { class: "label-text", "Herhaal wachtwoord" }, + } + input { + r#type: "password", + class: "input input-bordered w-full", + value: "{input_password_repeat}", + oninput: move |event| input_password_repeat.set(event.value()) + }, + } } div { - class: "card-actions mt-8", + class: "card-actions mt-5", button { class: "btn btn-primary", - "Volgende" + onclick: submit, + "Registreren" } } } } +#[server] +async fn login(email: String, password: String) -> Result<(), ServerFnError> { + // Verify username and password + let user_id = User::verify_credentials(email, password).await?; + + // Create a new session + let session = Session::new(user_id).await?; + session.set_cookie()?; + + Ok(()) +} + +#[server] +async fn register( + email: String, + password: String, + member_ids: Vec, +) -> Result<(), ServerFnError> { + // Creat a new user + let user = User::new(email, password, member_ids).await?; + + // Generate a session and set the cookie + let session = Session::new(user.id).await?; + session.set_cookie()?; + + Ok(()) +} + #[server] async fn fetch_member(registration_token: String) -> Result { let member = Member::fetch_from_registration_token(registration_token).await?; diff --git a/src/err.rs b/src/err.rs new file mode 100644 index 0000000..b3158ca --- /dev/null +++ b/src/err.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +#[cfg(feature = "server")] +#[derive(Error, Debug)] +pub enum Error { + #[error("Database error: {0}")] + SurrealDb(#[from] surrealdb::Error), + + #[error("No document returned")] + NoDocument, + + #[error("Invalid header value")] + InvalidHeaderValue(#[from] axum::http::header::InvalidHeaderValue), + + #[error("Could not create timestamp")] + ComponentRange(#[from] time::error::ComponentRange), + + #[error("No session cookie set")] + NoSessionCookie, + + #[error("Could not get cookie jar")] + CookieJar(String), +} diff --git a/src/main.rs b/src/main.rs index 8070e3b..a9e18a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,12 @@ #![allow(non_snake_case)] mod components; +mod err; mod util; +#[cfg(feature = "server")] +pub use err::Error; + use dioxus::prelude::*; use tracing::Level; diff --git a/src/util/model.rs b/src/util/model.rs index dd3fdb2..28735f0 100644 --- a/src/util/model.rs +++ b/src/util/model.rs @@ -1,2 +1,3 @@ pub mod member; +pub mod session; pub mod user; diff --git a/src/util/model/member/migration.rs b/src/util/model/member/migration.rs index 245b9b5..957661e 100644 --- a/src/util/model/member/migration.rs +++ b/src/util/model/member/migration.rs @@ -292,12 +292,12 @@ mod tests { }, ]; - let rows = match csv_to_rows(data) { + let rows = match MembersMigration::csv_to_rows(data) { Ok(r) => r, Err(err) => return Err(err.to_string()), }; - let members = rows_to_members(rows); + let members = MembersMigration::rows_to_members(rows); assert_eq!(expected, members); diff --git a/src/util/model/session.rs b/src/util/model/session.rs new file mode 100644 index 0000000..489246b --- /dev/null +++ b/src/util/model/session.rs @@ -0,0 +1,84 @@ +#[cfg(feature = "server")] +use crate::util::surrealdb::DB; +use dioxus::prelude::*; + +use serde::{Deserialize, Serialize}; + +use super::user::User; + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)] +pub struct Session { + id: String, + user_id: String, + expires: i64, + token: String, +} + +#[cfg(feature = "server")] +impl Session { + pub async fn new(user_id: String) -> Result { + let mut res = DB + .query("CREATE ONLY session SET user = type::thing('user', $user_id) RETURN record::id(id) as id, record::id(user) as user_id, time::unix(expires) as expires, token;") + .bind(("user_id", user_id)) + .await?; + + let session: Option = res.take(0)?; + + match session { + Some(s) => Ok(s), + None => Err(crate::Error::NoDocument), + } + } + + pub fn set_cookie(&self) -> Result<(), crate::Error> { + use axum::http::{header, HeaderValue}; + use axum_extra::extract::cookie::{Cookie, Expiration, SameSite}; + use dioxus::prelude::server_context; + use time::OffsetDateTime; + + let timestamp = OffsetDateTime::from_unix_timestamp(self.expires)?; + let expiration = Expiration::from(timestamp); + + let mut cookie = Cookie::build(("session_token", &self.token)) + .expires(expiration) + .build(); + + cookie.set_same_site(SameSite::Strict); + + server_context() + .response_parts_mut() + .unwrap() + .headers + .insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie.to_string())?, + ); + + Ok(()) + } + + pub async fn fetch_user_from_token(session_token: String) -> Result { + let mut res = DB + .query("SELECT record::id(user) as id, user.email as email FROM session WHERE token = $session_token") + .bind(("session_token", session_token)) + .await?; + + let user: Option = res.take(0)?; + + match user { + Some(u) => Ok(u), + None => Err(crate::Error::NoDocument), + } + } + + pub async fn get_token_from_cookie() -> Result { + use axum_extra::extract::CookieJar; + + let jar: CookieJar = extract().await.unwrap_or_default(); + + match jar.get("session_token") { + Some(s) => Ok(s.value().to_string()), + None => Err(crate::Error::NoSessionCookie), + } + } +} diff --git a/src/util/model/user.rs b/src/util/model/user.rs index 8b13789..43d16b6 100644 --- a/src/util/model/user.rs +++ b/src/util/model/user.rs @@ -1 +1,63 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "server")] +use crate::util::surrealdb::DB; +#[cfg(feature = "server")] +use surrealdb::sql::statements::{BeginStatement, CommitStatement}; + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)] +pub struct User { + pub id: String, + email: String, + password: Option, +} + +#[cfg(feature = "server")] +impl User { + pub async fn new( + email: String, + password: String, + member_ids: Vec, + ) -> Result { + // Create user record + let mut transaction = DB.query(BeginStatement::default()) + .query("let $user = CREATE ONLY user SET email = $email, password = crypto::argon2::generate($password) RETURN record::id(id) as id, email;") + .bind(("email", email)) + .bind(("password", password)); + + // Link user with members + for member_id in member_ids { + transaction = transaction.query(format!("RELATE ONLY (type::thing('user', $user.id))->user_to_member->(type::thing('member', $member_id_{}));", member_id)) + .bind((format!("member_id_{}", member_id), member_id)); + } + + let mut res = transaction + .query("RETURN $user") + .query(CommitStatement::default()) + .await?; + + let user: Option = res.take(0)?; + + match user { + Some(u) => Ok(u), + None => Err(crate::Error::NoDocument), + } + } + + pub async fn verify_credentials( + email: String, + password: String, + ) -> Result { + let mut res = DB + .query("SELECT VALUE record::id(id) as id FROM user WHERE email = $email AND crypto::argon2::compare(password, $password)") + .bind(("email", email)).bind(("password", password)) + .await?; + + let id: Option = res.take(0)?; + + match id { + Some(i) => Ok(i), + None => Err(crate::Error::NoDocument), + } + } +} diff --git a/src/util/surrealdb.rs b/src/util/surrealdb.rs index f588070..21cfb74 100644 --- a/src/util/surrealdb.rs +++ b/src/util/surrealdb.rs @@ -15,5 +15,43 @@ pub async fn initialize() -> surrealdb::Result<()> { } async fn apply_queries() -> surrealdb::Result<()> { + // Define user table + DB.query( + " + DEFINE TABLE OVERWRITE user SCHEMAFULL; + + DEFINE FIELD email ON TABLE user TYPE string + VALUE string::lowercase($value) + ASSERT string::is::email($value); + DEFINE FIELD password ON TABLE user TYPE string; + + DEFINE INDEX userEmailIndex ON TABLE user COLUMNS email UNIQUE; + ", + ) + .await?; + + DB.query( + " + DEFINE TABLE OVERWRITE user_to_member SCHEMAFULL TYPE RELATION FROM user TO member ENFORCED; + ", + ) + .await?; + + // Define session table + DB.query( + " + DEFINE TABLE OVERWRITE session SCHEMAFULL; + + DEFINE FIELD user ON TABLE session TYPE record; + DEFINE FIELD token ON TABLE session TYPE string + VALUE rand::string(32); + DEFINE FIELD expires ON TABLE session TYPE datetime + VALUE time::now() + 1w; + + DEFINE INDEX sessionTokenIndex ON TABLE session COLUMNS token UNIQUE; + ", + ) + .await?; + Ok(()) }