Added basic auth system

This commit is contained in:
xeovalyte 2024-09-30 17:01:13 +02:00
parent a503726b42
commit 5ca02189f4
Signed by: xeovalyte
SSH Key Fingerprint: SHA256:kSQDrQDmKzljJzfGYcd3m9RqHi4h8rSwkZ3sQ9kBURo
12 changed files with 466 additions and 97 deletions

144
Cargo.lock generated
View File

@ -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]]

View File

@ -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"]

View File

@ -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 {

View File

@ -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<User, ServerFnError> {
let token = Session::get_token_from_cookie().await?;
let user = Session::fetch_user_from_token(token).await?;
Ok(user)
}

View File

@ -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,8 +47,17 @@ 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 {
@ -56,25 +67,29 @@ fn Login() -> Element {
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-5",
class: "form-control w-full mt-3",
div {
class: "label",
span { class: "label-text", "Password" },
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-8",
class: "card-actions mt-5",
button {
class: "btn btn-primary",
"Inloggen"
}
onclick: submit,
"Inloggen",
}
}
}
@ -82,7 +97,20 @@ fn Login() -> Element {
fn Register() -> Element {
let mut members: Signal<HashMap<String, Member>> = 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<String> = 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<String>,
) -> 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<Member, ServerFnError> {
let member = Member::fetch_from_registration_token(registration_token).await?;

23
src/err.rs Normal file
View File

@ -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),
}

View File

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

View File

@ -1,2 +1,3 @@
pub mod member;
pub mod session;
pub mod user;

View File

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

84
src/util/model/session.rs Normal file
View File

@ -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<Self, crate::Error> {
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<Self> = 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<User, crate::Error> {
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<User> = res.take(0)?;
match user {
Some(u) => Ok(u),
None => Err(crate::Error::NoDocument),
}
}
pub async fn get_token_from_cookie() -> Result<String, crate::Error> {
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),
}
}
}

View File

@ -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<String>,
}
#[cfg(feature = "server")]
impl User {
pub async fn new(
email: String,
password: String,
member_ids: Vec<String>,
) -> Result<Self, crate::Error> {
// 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<Self> = res.take(0)?;
match user {
Some(u) => Ok(u),
None => Err(crate::Error::NoDocument),
}
}
pub async fn verify_credentials(
email: String,
password: String,
) -> Result<String, crate::Error> {
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<String> = res.take(0)?;
match id {
Some(i) => Ok(i),
None => Err(crate::Error::NoDocument),
}
}
}

View File

@ -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(())
}