feat: Users

users
Miroito 2 years ago
parent d7655c3ebe
commit 792b0d96ce

1026
client/Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -9,7 +9,7 @@ edition = "2021"
chrono = { version = "0.4.23", features = ["serde"] } chrono = { version = "0.4.23", features = ["serde"] }
serde = { version = "1.0.152", features = ["derive"] } serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.91" serde_json = "1.0.91"
perseus = { version = "0.4.1", features = ["hydrate"] } perseus = { version = "0.4.2", features = ["hydrate"] }
sycamore = { version = "^0.8.1", features = [ sycamore = { version = "^0.8.1", features = [
"ssr", "ssr",
"serde", "serde",
@ -17,6 +17,7 @@ sycamore = { version = "^0.8.1", features = [
"hydrate", "hydrate",
] } ] }
lazy_static = "1" lazy_static = "1"
wasm-cookies = "0.2"
[target.'cfg(engine)'.dev-dependencies] [target.'cfg(engine)'.dev-dependencies]
fantoccini = "^0.19.3" fantoccini = "^0.19.3"

@ -0,0 +1,68 @@
use crate::api::{types::user::UserProfile, FastInsidersApi};
#[cfg(client)]
use super::user::set_token_cookie;
#[cfg(engine)]
type Response = reqwest::Response;
#[cfg(client)]
type Response = reqwasm::http::Response;
#[cfg(client)]
fn update_token(resp: &Response) {
if let Some(token) = resp.headers().get("x-new-token") {
set_token_cookie(&token)
}
}
#[cfg(client)]
async fn get_auth_route(route: &str) -> Result<Response, ()> {
let token = wasm_cookies::get_raw("token").unwrap_or_default();
let resp = reqwasm::http::Request::get(route)
.header("Authorization", &format!("Bearer {}", token))
.send()
.await
.map_err(|_| ())?;
update_token(&resp);
Ok(resp)
}
impl FastInsidersApi {
/// This is only a route to verify that we are authenticated
pub async fn is_authenticated(&self) -> Result<Response, ()> {
let route = &format!("{}/auth/is_authenticated", self.url);
#[cfg(client)]
let resp = get_auth_route(route).await?;
#[cfg(engine)]
let resp = reqwest::Client::new()
.get(route)
.send()
.await
.map_err(|_| ())?;
Ok(resp)
}
pub async fn get_profile(&self) -> Result<UserProfile, ()> {
let route = &format!("{}/auth/profile", self.url);
#[cfg(client)]
let res = {
let resp = get_auth_route(route).await?;
resp.json::<UserProfile>().await.map_err(|_| ())?
};
#[cfg(engine)]
let res = reqwest::get(route)
.await
.map_err(|_| ())?
.json::<UserProfile>()
.await
.map_err(|_| ())?;
Ok(res)
}
}

@ -1,2 +1,4 @@
pub mod authenticated;
pub mod company; pub mod company;
pub mod transaction; pub mod transaction;
pub mod user;

@ -24,13 +24,16 @@ impl FastInsidersApi {
); );
#[cfg(client)] #[cfg(client)]
let res = reqwasm::http::Request::get(route) let res = {
use reqwasm::http::RequestCredentials;
reqwasm::http::Request::get(route)
.send() .send()
.await .await
.map_err(|_| ())? .map_err(|_| ())?
.json::<PaginatedResponse<TransactionCompany>>() .json::<PaginatedResponse<TransactionCompany>>()
.await .await
.map_err(|_| ())?; .map_err(|_| ())?
};
#[cfg(engine)] #[cfg(engine)]
let res = reqwest::get(route) let res = reqwest::get(route)

@ -0,0 +1,163 @@
use std::{error::Error, fmt::Display, time::Duration};
use serde::{Deserialize, Serialize};
use crate::api::FastInsidersApi;
#[cfg(client)]
#[derive(Serialize)]
pub struct UserLoginBody {
pub name: String,
pub password: String,
}
#[cfg(client)]
#[derive(Serialize)]
pub struct UserRegisterBody {
pub name: String,
pub email: String,
pub password: String,
}
#[cfg(client)]
type Response = reqwasm::http::Response;
#[cfg(client)]
#[derive(Deserialize)]
struct LoginResponse {
pub token: String,
}
#[cfg(client)]
#[derive(Debug, Deserialize)]
pub enum LoginError {
Unknown,
InvalidCredentials,
InternalServer(String),
Server(String),
UserNotFound(String),
}
#[cfg(client)]
#[derive(Deserialize)]
struct ErrorBody {
error: String,
}
#[cfg(client)]
impl Display for LoginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unknown => {
write!(f, "There was an unknown error while trying to log you in.").to_owned()
}
Self::InternalServer(e) => write!(f, "Internal server error: {}", e),
Self::Server(e) => write!(f, "There was an error completing the request {}", e),
Self::InvalidCredentials => write!(f, "Invalid username or password"),
Self::UserNotFound(e) => write!(f, "{e}"),
}
}
}
#[cfg(client)]
impl Error for LoginError {}
#[cfg(client)]
#[derive(Debug, Deserialize)]
pub enum RegisterError {
Unknown,
InternalServer(String),
Server(String),
Conflict(String),
}
#[cfg(client)]
impl Display for RegisterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unknown => {
write!(f, "There was an unknown error while trying to log you in.").to_owned()
}
Self::InternalServer(e) => write!(f, "Internal server error: {}", e),
Self::Server(e) => write!(f, "There was an error completing the request {}", e),
Self::Conflict(e) => write!(f, "{e}"),
}
}
}
#[cfg(client)]
impl Error for RegisterError {}
#[cfg(client)]
pub fn set_token_cookie(token: &str) {
use wasm_cookies::CookieOptions;
let cookie_options = CookieOptions::default()
.secure()
.with_same_site(wasm_cookies::SameSite::Strict)
.expires_after(Duration::from_secs(60 * 60 * 24 * 5)); // 5 days
wasm_cookies::set("token", token, &cookie_options);
}
#[cfg(client)]
impl FastInsidersApi {
pub async fn login(&self, body: &UserLoginBody) -> Result<(), LoginError> {
let route = &format!("{}/user/login", self.url);
let resp = reqwasm::http::Request::post(route)
.header("Content-type", "application/json")
.body(serde_json::to_string(&body).unwrap())
.send()
.await
.map_err(|e| LoginError::Server(e.to_string()))?;
if resp.status() == 200 {
if let Ok(data) = resp.json::<LoginResponse>().await {
set_token_cookie(&data.token);
return Ok(());
} else {
panic!();
}
}
match resp.status() {
401 => return Err(LoginError::InvalidCredentials),
500 => {
let error = resp.json::<ErrorBody>().await.unwrap().error;
return Err(LoginError::InternalServer(error));
}
404 => {
let error = resp.json::<ErrorBody>().await.unwrap().error;
return Err(LoginError::UserNotFound(error));
}
_ => return Err(LoginError::Unknown),
}
}
pub async fn register(&self, body: &UserRegisterBody) -> Result<(), RegisterError> {
let route = &format!("{}/user/register", self.url);
use wasm_cookies::CookieOptions;
let resp = reqwasm::http::Request::post(route)
.header("Content-type", "application/json")
.body(serde_json::to_string(&body).unwrap())
.send()
.await
.map_err(|e| RegisterError::Server(e.to_string()))?;
if resp.status() == 200 {
return Ok(());
}
if resp.status() == 500 {
let error = resp.json::<ErrorBody>().await.unwrap().error;
return Err(RegisterError::InternalServer(error));
}
if resp.status() == 409 {
let error = resp.json::<ErrorBody>().await.unwrap().error;
return Err(RegisterError::Conflict(error));
}
Err(RegisterError::Unknown)
}
}

@ -1,3 +1,4 @@
pub mod company; pub mod company;
pub mod paginated_response; pub mod paginated_response;
pub mod transaction; pub mod transaction;
pub mod user;

@ -12,7 +12,7 @@ where
fn into_table_data(self, cx: Scope) -> TableContent<G>; fn into_table_data(self, cx: Scope) -> TableContent<G>;
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Deserialize)]
pub struct PaginatedResponse<M> { pub struct PaginatedResponse<M> {
pub count: i64, pub count: i64,
pub num_pages: i64, pub num_pages: i64,

@ -0,0 +1,7 @@
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct UserProfile {
pub email: Option<String>,
pub name: String,
}

@ -20,35 +20,19 @@ fn dark_mode_btn<G: Html>(cx: Scope, _props: ()) -> View<G> {
view! { cx, view! { cx,
(if *dark_mode.get() { (if *dark_mode.get() {
view! {cx, view! {cx,
div(on:click=toggle_dark_mode, class="py-1 px-2 mx-1 rounded-full bg-slate-200 dark:bg-slate-800") { div(on:click=toggle_dark_mode, class="hover:cursor-pointer") {
svg(xmlns="http://www.w3.org/2000/svg", version="1.1", width="25", height="25", viewBox="0 0 256 256") { svg(xmlns="http://www.w3.org/2000/svg", fill="none", viewBox="0 0 24 24", stroke-width="1.5", stroke="currentColor", class="w-6 h-6") {
g(style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;", transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)") { path(stroke-linecap="round", stroke-linejoin="round", d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z") {}
path(d="M 89.634 59.683 c -0.338 -0.276 -0.816 -0.302 -1.184 -0.062 c -16.514 10.864 -38.661 8.589 -52.661 -5.41 C 21.79 40.212 19.515 18.065 30.38 1.551 c 0.24 -0.366 0.215 -0.845 -0.062 -1.183 c -0.277 -0.339 -0.741 -0.46 -1.148 -0.294 c -5.826 2.349 -11.048 5.809 -15.523 10.283 c -18.195 18.195 -18.195 47.802 0 65.997 C 22.744 85.451 34.695 90 46.645 90 c 11.951 0 23.901 -4.549 32.999 -13.646 c 4.475 -4.476 7.935 -9.699 10.284 -15.523 C 90.091 60.425 89.972 59.96 89.634 59.683 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(230,230,230); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 77.254 40.17 c -4.894 -1.63 -8.788 -5.525 -10.42 -10.419 c -0.27 -0.81 -0.992 -1.334 -1.841 -1.334 c -0.848 0 -1.571 0.524 -1.84 1.335 c -1.631 4.893 -5.526 8.787 -10.419 10.418 c -0.811 0.27 -1.334 0.993 -1.334 1.841 c 0 0.848 0.524 1.571 1.334 1.841 c 4.894 1.631 8.788 5.525 10.418 10.419 h 0.001 c 0.27 0.811 0.992 1.334 1.84 1.334 c 0.849 0 1.572 -0.524 1.841 -1.334 c 1.631 -4.893 5.526 -8.788 10.419 -10.419 c 0.812 -0.27 1.335 -0.992 1.335 -1.841 C 78.588 41.162 78.064 40.439 77.254 40.17 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(230,230,230); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 81.635 11.577 c -2.597 -0.865 -4.664 -2.932 -5.53 -5.529 c -0.208 -0.626 -0.789 -1.046 -1.446 -1.046 c -0.657 0 -1.239 0.421 -1.448 1.047 c -0.864 2.596 -2.93 4.663 -5.527 5.528 c -0.626 0.208 -1.047 0.789 -1.047 1.446 s 0.421 1.238 1.046 1.446 c 2.596 0.865 4.663 2.932 5.529 5.529 c 0.208 0.625 0.788 1.046 1.445 1.047 c 0.001 0 0.001 0 0.002 0 c 0.656 0 1.238 -0.421 1.446 -1.046 c 0.866 -2.597 2.933 -4.664 5.53 -5.529 c 0.625 -0.209 1.046 -0.79 1.046 -1.446 C 82.681 12.367 82.26 11.786 81.635 11.577 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(230,230,230); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 52.274 18.689 c -3.232 -1.076 -5.805 -3.649 -6.882 -6.881 c -0.224 -0.674 -0.849 -1.126 -1.556 -1.126 c -0.706 0 -1.331 0.453 -1.556 1.126 c -1.077 3.232 -3.649 5.804 -6.881 6.881 c -0.674 0.224 -1.126 0.849 -1.126 1.556 s 0.453 1.331 1.126 1.556 c 3.232 1.077 5.805 3.65 6.881 6.882 c 0.224 0.674 0.849 1.126 1.556 1.126 c 0.706 0 1.331 -0.453 1.556 -1.126 c 1.077 -3.232 3.649 -5.805 6.881 -6.882 c 0.674 -0.224 1.127 -0.849 1.127 -1.556 S 52.947 18.913 52.274 18.689 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(230,230,230); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
}
} }
} }
} }
} else { } else {
view! {cx, view! {cx,
div(on:click=toggle_dark_mode, class="py-1 px-2 mx-1 rounded-full bg-slate-200 dark:bg-slate-800") { div(on:click=toggle_dark_mode, class="hover:cursor-pointer") {
svg(xmlns="http://www.w3.org/2000/svg", version="1.1", width="25", height="25", viewBox="0 0 256 256") { svg(xmlns="http://www.w3.org/2000/svg", fill="none", viewBox="0 0 24 24", stroke-width="1.5", stroke="currentColor", class="w-6 h-6") {
g(style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;", transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)") { path(stroke-linecap="round", stroke-linejoin="round", d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z") {}
path(d="M 45 68 c -12.682 0 -23 -10.317 -23 -23 c 0 -12.682 10.318 -23 23 -23 c 12.683 0 23 10.318 23 23 C 68 57.683 57.683 68 45 68 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 45 17.556 c -1.657 0 -3 -1.343 -3 -3 V 3 c 0 -1.657 1.343 -3 3 -3 c 1.657 0 3 1.343 3 3 v 11.556 C 48 16.212 46.657 17.556 45 17.556 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 45 90 c -1.657 0 -3 -1.343 -3 -3 V 75.444 c 0 -1.657 1.343 -3 3 -3 c 1.657 0 3 1.343 3 3 V 87 C 48 88.657 46.657 90 45 90 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 14.556 48 H 3 c -1.657 0 -3 -1.343 -3 -3 c 0 -1.657 1.343 -3 3 -3 h 11.556 c 1.657 0 3 1.343 3 3 C 17.556 46.657 16.212 48 14.556 48 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 87 48 H 75.444 c -1.657 0 -3 -1.343 -3 -3 c 0 -1.657 1.343 -3 3 -3 H 87 c 1.657 0 3 1.343 3 3 C 90 46.657 88.657 48 87 48 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 66.527 26.473 c -0.768 0 -1.535 -0.293 -2.121 -0.878 c -1.172 -1.172 -1.172 -3.071 0 -4.243 l 8.171 -8.171 c 1.172 -1.172 3.07 -1.171 4.242 0 c 1.172 1.172 1.172 3.071 0 4.243 l -8.171 8.171 C 68.063 26.18 67.295 26.473 66.527 26.473 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 15.302 77.698 c -0.768 0 -1.536 -0.293 -2.121 -0.879 c -1.172 -1.171 -1.172 -3.071 0 -4.242 l 8.171 -8.171 c 1.171 -1.172 3.071 -1.172 4.242 0 c 1.172 1.171 1.172 3.071 0 4.242 l -8.171 8.171 C 16.837 77.405 16.069 77.698 15.302 77.698 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 23.473 26.473 c -0.768 0 -1.536 -0.293 -2.121 -0.878 l -8.171 -8.171 c -1.172 -1.172 -1.172 -3.071 0 -4.243 c 1.172 -1.172 3.072 -1.171 4.243 0 l 8.171 8.171 c 1.172 1.172 1.172 3.071 0 4.243 C 25.008 26.18 24.24 26.473 23.473 26.473 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
path(d="M 74.698 77.698 c -0.768 0 -1.535 -0.293 -2.121 -0.879 l -8.171 -8.171 c -1.172 -1.171 -1.172 -3.071 0 -4.242 c 1.172 -1.172 3.07 -1.172 4.242 0 l 8.171 8.171 c 1.172 1.171 1.172 3.071 0 4.242 C 76.233 77.405 75.466 77.698 74.698 77.698 z", style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;", transform=" matrix(1 0 0 1 0 0) ", stroke-linecap="round") {}
}
} }
} }
} }
}) })
} }

@ -1 +1,2 @@
pub mod dark_mode_btn; pub mod dark_mode_btn;
pub mod user_icon;

@ -0,0 +1,89 @@
use lazy_static::lazy_static;
use perseus::prelude::*;
use sycamore::{prelude::*, rt::Event};
use crate::global_state::AppStateRx;
lazy_static! {
pub static ref USER_ICON: Capsule<PerseusNodeType, ()> = get_capsule();
}
fn user_icon<G: Html>(cx: Scope, _props: ()) -> View<G> {
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
let api = global_state.api.get();
let api_scope_ref = create_ref(cx, api);
#[cfg(client)]
spawn_local_scoped(cx, async move {
let status = api_scope_ref.is_authenticated().await.unwrap().status();
if status == 200 {
global_state.logged_in.set(true);
} else {
global_state.logged_in.set(false);
}
});
#[cfg(client)]
let logout = move |e: Event| {
wasm_cookies::delete("token");
global_state.logged_in.set(false);
};
#[cfg(engine)]
let logout = move |_| {};
view! { cx,
(if *global_state.logged_in.get() {
view! { cx,
div(on:click=|_| navigate("/profile"), class="mx-1 hover:cursor-pointer", title="Login") {
// user profile icon
svg(xmlns="http://www.w3.org/2000/svg", fill="none", viewBox="0 0 24 24", stroke-width="1.5", stroke="currentColor", class="w-6 h-6") {
path(stroke-linecap="round", stroke-linejoin="round", d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z") {}
}
}
div(on:click=logout, class="mx-1 hover:cursor-pointer", title="Login") {
// logout icon
svg(xmlns="http://www.w3.org/2000/svg", width="24", height="24", viewBox="0 0 24 24", fill="none", stroke="currentColor", stroke-width="2", stroke-linecap="round", stroke-linejoin="round", class="feather feather-log-out") {
path(d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"){}
polyline(points="16 17 21 12 16 7") {}
line(x1="21", y1="12", x2="9", y2="12") {}
}
}
}
} else {
view! { cx,
div(on:click=|_| navigate("/login"), class="mx-1 hover:cursor-pointer", title="Login") {
// login icon
svg(xmlns="http://www.w3.org/2000/svg", width="24", height="24", viewBox="0 0 24 24", fill="none", stroke="currentColor", stroke-width="2", stroke-linecap="round", stroke-linejoin="round", class="feather feather-log-in") {
path(d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4") {}
polyline(points="10 17 15 12 10 7") {}
line(x1="15", y1="12", x2="3", y2="12"){}
}
}
}
})
}
}
fn fallback<G: Html>(cx: Scope, _props: ()) -> View<G> {
view! { cx,
div(on:click=|_| navigate("/login"), class="mx-1 hover:cursor-pointer", title="Login") {
// login icon
svg(xmlns="http://www.w3.org/2000/svg", width="24", height="24", viewBox="0 0 24 24", fill="none", stroke="currentColor", stroke-width="2", stroke-linecap="round", stroke-linejoin="round", class="feather feather-log-in") {
path(d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4") {}
polyline(points="10 17 15 12 10 7") {}
line(x1="15", y1="12", x2="3", y2="12"){}
}
}
}
}
pub fn get_capsule<G: Html>() -> Capsule<G, ()> {
Capsule::build(Template::build("dark_mode_btn"))
.fallback(fallback)
.view(user_icon)
.build()
}

@ -12,8 +12,8 @@ pub fn MainContentContainer<'a, G: Html>(cx: Scope<'a>, props: MainProps<'a, G>)
let children = props.children.call(cx); let children = props.children.call(cx);
view! {cx, view! {cx,
div (id="main", class="flex flex-col items-center justify-center ") { div (id="main", class="flex flex-col justify-center items-center") {
div (class="w-4/5 m-10 p-3 bg-slate-100 dark:bg-slate-600 rounded-lg items-center justify-center") { div (class="justify-center items-center p-3 m-10 w-4/5 rounded-lg bg-slate-100 dark:bg-slate-600") {
(children) (children)
} }
} }

@ -1,7 +1,7 @@
use perseus::prelude::*; use perseus::prelude::*;
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::capsules::dark_mode_btn::DARK_MODE_BTN; use crate::capsules::{dark_mode_btn::DARK_MODE_BTN, user_icon::USER_ICON};
#[component] #[component]
pub fn TheHeader<G: Html>(cx: Scope) -> View<G> { pub fn TheHeader<G: Html>(cx: Scope) -> View<G> {
@ -18,9 +18,14 @@ pub fn TheHeader<G: Html>(cx: Scope) -> View<G> {
"All transactions" "All transactions"
} }
} }
div (class="flex-none") { div (class="flex items-center pl-3 border-l border-slate-700 dark:border-slate-300") {
div(class="mx-1") {
(DARK_MODE_BTN.widget(cx,"",())) (DARK_MODE_BTN.widget(cx,"",()))
} }
div(class="flex mx-1") {
(USER_ICON.widget(cx,"",()))
}
}
} }
} }
} }

@ -15,6 +15,7 @@ pub fn get_global_state_creator() -> GlobalStateCreator {
#[rx(alias = "AppStateRx")] #[rx(alias = "AppStateRx")]
pub struct AppState { pub struct AppState {
pub dark_mode: bool, pub dark_mode: bool,
pub logged_in: bool,
pub api: FastInsidersApi, pub api: FastInsidersApi,
} }
@ -22,6 +23,7 @@ pub struct AppState {
pub async fn get_build_state() -> AppState { pub async fn get_build_state() -> AppState {
AppState { AppState {
dark_mode: true, dark_mode: true,
logged_in: false,
api: FastInsidersApi::new(""), // It's unfortunately not possible to have a different type api: FastInsidersApi::new(""), // It's unfortunately not possible to have a different type
// for the build state and the request state, I would rather // for the build state and the request state, I would rather
// have left this out // have left this out
@ -34,9 +36,12 @@ pub async fn get_build_state() -> AppState {
async fn get_request_state(_req: Request) -> AppState { async fn get_request_state(_req: Request) -> AppState {
use crate::env::Config; use crate::env::Config;
let config = Config::new(); let config = Config::new();
let api = FastInsidersApi::new(&config.api_url);
AppState { AppState {
dark_mode: true, dark_mode: true,
api: FastInsidersApi::new(&config.api_url), logged_in: false,
api,
} }
} }
@ -44,6 +49,7 @@ async fn get_request_state(_req: Request) -> AppState {
async fn amalgamate_states(build_state: AppState, request_state: AppState) -> AppState { async fn amalgamate_states(build_state: AppState, request_state: AppState) -> AppState {
AppState { AppState {
dark_mode: build_state.dark_mode, dark_mode: build_state.dark_mode,
logged_in: build_state.logged_in,
api: request_state.api, api: request_state.api,
} }
} }

@ -14,6 +14,9 @@ pub fn main<G: Html>() -> PerseusApp<G> {
PerseusApp::new() PerseusApp::new()
.template(crate::templates::index::get_template()) .template(crate::templates::index::get_template())
.template(crate::templates::transactions::get_template()) .template(crate::templates::transactions::get_template())
.template(crate::templates::login::get_template())
.template(crate::templates::profile::get_template())
.template(crate::templates::register::get_template())
.capsule_ref(&*crate::capsules::dark_mode_btn::DARK_MODE_BTN) .capsule_ref(&*crate::capsules::dark_mode_btn::DARK_MODE_BTN)
.global_state_creator(crate::global_state::get_global_state_creator()) .global_state_creator(crate::global_state::get_global_state_creator())
.error_views(crate::error_pages::get_error_views()) .error_views(crate::error_pages::get_error_views())

@ -0,0 +1,98 @@
use perseus::prelude::*;
use sycamore::{prelude::*, rt::Event};
use crate::{
components::{main_content_container::MainContentContainer, the_header::TheHeader},
global_state::AppStateRx,
};
fn login_page<G: Html>(cx: BoundedScope) -> View<G> {
let reactor = Reactor::<G>::from_cx(cx);
let global_state = reactor.get_global_state::<AppStateRx>(cx);
let api = global_state.api.get();
let api_scope_ref = create_ref(cx, api);
let dark_mode_class = create_memo(cx, || {
if *global_state.dark_mode.get() {
"dark"
} else {
""
}
});
let username = create_signal(cx, "".to_string());
let password = create_signal(cx, "".to_string());
let error_msg = create_signal(cx, "".to_string());
create_effect(cx, move || {
if *global_state.logged_in.get() {
navigate("/");
}
});
let submit_disabled = create_memo(cx, move || {
username.get().is_empty() || password.get().is_empty()
});
#[cfg(client)]
let submit_login = move |e: Event| {
use crate::api::routes::user::UserLoginBody;
e.prevent_default();
let user_info = UserLoginBody {
name: username.get().to_string(),
password: password.get().to_string(),
};
spawn_local_scoped(cx, async move {
match api_scope_ref.login(&user_info).await {
Ok(()) => {
global_state.logged_in.set(true);
}
Err(e) => error_msg.set(e.to_string()),
};
})
};
#[cfg(engine)]
let submit_login = move |_| {};
view! {cx,
main (class=format!("{} flex flex-1", dark_mode_class)) {
div (class="flex-1 font-sans bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-100") {
TheHeader()
MainContentContainer(useless_prop=1) {
form(class="flex flex-col justify-center items-center") {
label(for="username") { "Username:" }
input(id="username", bind:value=username, type="text", class="p-2 m-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800") {}
label(for="password") { "Password:" }
input(id="password", bind:value=password, type="password", class="p-2 m-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800") {}
input(on:click=submit_login,
value="Login",
type="submit",
class="p-2 m-2 rounded-md hover:cursor-pointer disabled:cursor-not-allowed bg-slate-300 dark:hover:bg-slate-900 dark:bg-slate-800 hover:bg-slate-400",
disabled=*submit_disabled.get()
) {}
p (class="text-red-700 dark:text-rose-500") {
(error_msg.get())
}
}
a (class="hover:underline", href="/register") {
"Don't have an account? Register here."
}
}
}
}
}
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("login").head(head).view(login_page).build()
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! {cx,
title { "Fast Insiders" }
}
}

@ -1,2 +1,5 @@
pub mod index; pub mod index;
pub mod login;
pub mod profile;
pub mod register;
pub mod transactions; pub mod transactions;

@ -0,0 +1,105 @@
use perseus::prelude::*;
use sycamore::{prelude::*, rt::Event};
use crate::{
components::{
loading::Loading, main_content_container::MainContentContainer, the_header::TheHeader,
},
global_state::AppStateRx,
};
fn profile_page<G: Html>(cx: BoundedScope) -> View<G> {
let reactor = Reactor::<G>::from_cx(cx);
let global_state = reactor.get_global_state::<AppStateRx>(cx);
let api = global_state.api.get();
let api_scope_ref = create_ref(cx, api);
let dark_mode_class = create_memo(cx, || {
if *global_state.dark_mode.get() {
"dark"
} else {
""
}
});
let username = create_signal(cx, "".to_string());
let email = create_signal(cx, "".to_string());
let loading: &Signal<bool> = create_signal(cx, true);
#[cfg(client)]
spawn_local_scoped(cx, async move {
let resp = api_scope_ref.get_profile().await.unwrap();
username.set(resp.name);
if let Some(em) = resp.email {
email.set(em);
}
loading.set(false);
});
let displayed_email = create_memo(cx, move || {
if email.get().is_empty() {
"No email set".to_string()
} else {
(*email.get()).clone()
}
});
view! {cx,
main (class=format!("{} flex flex-1", dark_mode_class)) {
div (class="flex-1 font-sans bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-100") {
TheHeader()
MainContentContainer(useless_prop=1) {
div(class="items-center m-auto w-2/5 min-w-70") {
h1(class="text-lg text-center") {
"Profile page"
}
(if !*loading.get() {
view! {cx,
p() {
b() {
"Username: "
}
input(
class="p-2 w-full rounded-md bg-slate-300 dark:bg-slate-800",
disabled=true,
value=username.get(),
)
}
p() {
b() {
"Email: "
}
input(
class="p-2 w-full rounded-md bg-slate-300 dark:bg-slate-800",
disabled=true,
value=displayed_email.get(),
)
}
}
} else {
view! {cx,
Loading()
}
})
}
}
}
}
}
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("profile")
.head(head)
.view(profile_page)
.build()
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! {cx,
title { "Fast Insiders - User Profile" }
}
}

@ -0,0 +1,133 @@
use perseus::prelude::*;
use sycamore::{prelude::*, rt::Event};
use crate::{
components::{main_content_container::MainContentContainer, the_header::TheHeader},
global_state::AppStateRx,
};
fn register_page<G: Html>(cx: BoundedScope) -> View<G> {
let reactor = Reactor::<G>::from_cx(cx);
let global_state = reactor.get_global_state::<AppStateRx>(cx);
let api = global_state.api.get();
let api_scope_ref = create_ref(cx, api);
let dark_mode_class = create_memo(cx, || {
if *global_state.dark_mode.get() {
"dark"
} else {
""
}
});
let username = create_signal(cx, "".to_string());
let email = create_signal(cx, "".to_string());
let password = create_signal(cx, "".to_string());
let confirm_password = create_signal(cx, "".to_string());
let error_msg = create_signal(cx, "".to_string());
create_effect(cx, move || {
if *global_state.logged_in.get() {
navigate("/");
}
});
let passwords_match = move |e: Event| {
if confirm_password.get() != password.get() {
error_msg.set("Passwords do not match".to_string());
} else {
error_msg.set("".to_string());
}
};
let submit_disabled = create_memo(cx, move || {
username.get().is_empty()
|| password.get().is_empty()
|| password.get() != confirm_password.get()
});
#[cfg(client)]
let submit_register = move |e: Event| {
use crate::api::routes::user::UserRegisterBody;
e.prevent_default();
let user_info = UserRegisterBody {
name: username.get().to_string(),
email: email.get().to_string(),
password: password.get().to_string(),
};
spawn_local_scoped(cx, async move {
match api_scope_ref.register(&user_info).await {
Ok(()) => navigate("/login"),
Err(e) => error_msg.set(e.to_string()),
};
})
};
#[cfg(engine)]
let submit_register = move |_| {};
view! {cx,
main (class=format!("{} flex flex-1", dark_mode_class)) {
div (class="flex-1 font-sans bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-100") {
TheHeader()
MainContentContainer(useless_prop=1) {
form(class="flex flex-col justify-center items-center") {
label(for="username") { "Username:" }
input(id="username",
bind:value=username,
type="text",
class="p-2 m-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800"
) {}
label(for="password") { "Password:" }
input(id="password",
bind:value=password,
type="password",
class="p-2 m-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800"
) {}
label(for="confirm-password") { "Confirm Password:" }
input(id="confirm-password",
bind:value=confirm_password,
on:blur=passwords_match,
type="password",
class="p-2 m-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800"
) {}
label(for="Email") { "Email:" }
input(id="email",
bind:value=email,
type="text",
class="p-2 mx-2 w-1/3 rounded-md bg-slate-300 dark:bg-slate-800"
) {}
p (class="mt-0 italic") {
"Set an email if you want to be able to reset your password"
}
input(on:click=submit_register,
value="Register",
type="submit",
class="p-2 m-2 rounded-md hover:cursor-pointer disabled:cursor-not-allowed bg-slate-300 dark:hover:bg-slate-900 dark:bg-slate-800 hover:bg-slate-400",
disabled=*submit_disabled.get()
) {}
p (class="text-red-700 dark:text-rose-500") {
(error_msg.get())
}
}
}
}
}
}
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("register")
.head(head)
.view(register_page)
.build()
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! {cx,
title { "Fast Insiders - Register" }
}
}

File diff suppressed because one or more lines are too long

1
server/.gitignore vendored

@ -1 +1,2 @@
target/ target/
keys/

1759
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -6,19 +6,19 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
chrono = { version = "0.4.23", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0.152", features = ["derive"] } serde = { version = "1", features = ["derive"] }
dotenvy = "0.15.6" dotenvy = "0.15"
envy = "0.4.2" envy = "0.4"
serde_json = "1.0.91" serde_json = "1"
migration = { version = "0.1.0", path = "./migration" } migration = { version = "0.1.0", path = "./migration" }
tokio = { version = "^1.20.1", features = ["full"] } tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json", "rustls-tls"] } reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
axum = "0.6.12" axum = { version = "0.6", features = ["headers", "macros"] }
hyper = { version = "0.14.25", features = ["full"] } hyper = { version = "0.14", features = ["full"] }
tower = "0.4" tower = "0.4"
tower-cookies = "0.9" tower-cookies = "0.9"
sea-orm = { version = "0.11.0", features = [ sea-orm = { version = "0.11", features = [
"runtime-tokio-rustls", "runtime-tokio-rustls",
"macros", "macros",
"sqlx-mysql", "sqlx-mysql",
@ -33,8 +33,11 @@ thiserror = "1.0.38"
slug = "0.1.4" slug = "0.1.4"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.4", features = ["trace", "cors"] } tower-http = { version = "0.4", features = ["trace", "cors"] }
http = "0.2"
tracing = "0.1" tracing = "0.1"
rust-argon2 = "1" rust-argon2 = "1"
rand = "0.8" rand = "0.8"
jsonwebtoken = "8" jsonwebtoken = "8"
cookie = { version = "0.17", features = [ "secure" ] } cookie = { version = "0.17", features = [ "secure" ] }
rsa = { version = "0.9.2", features = [ "pem" ] }
time = "0.3"

File diff suppressed because it is too large Load Diff

@ -18,7 +18,7 @@ impl MigrationTrait for Migration {
.auto_increment() .auto_increment()
.primary_key(), .primary_key(),
) )
.col(ColumnDef::new(User::Email).string().not_null().unique_key()) .col(ColumnDef::new(User::Email).string().unique_key())
.col(ColumnDef::new(User::Name).string().not_null().unique_key()) .col(ColumnDef::new(User::Name).string().not_null().unique_key())
.col(ColumnDef::new(User::Password).string().not_null()) .col(ColumnDef::new(User::Password).string().not_null())
.to_owned(), .to_owned(),

@ -0,0 +1,21 @@
//! Custom serialization of OffsetDateTime to conform with the JWT spec (RFC 7519 section 2, "Numeric Date")
use serde::{self, Deserialize, Deserializer, Serializer};
use time::OffsetDateTime;
/// Serializes an OffsetDateTime to a Unix timestamp (milliseconds since 1970/1/1T00:00:00T)
pub fn serialize<S>(date: &OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let timestamp = date.unix_timestamp();
serializer.serialize_i64(timestamp)
}
/// Attempts to deserialize an i64 and use as a Unix timestamp
pub fn deserialize<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
where
D: Deserializer<'de>,
{
OffsetDateTime::from_unix_timestamp(i64::deserialize(deserializer)?)
.map_err(|_| serde::de::Error::custom("invalid Unix timestamp value"))
}

@ -0,0 +1,322 @@
use futures::StreamExt;
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use rsa::{
pkcs8::{der::zeroize::Zeroizing, EncodePrivateKey, EncodePublicKey, LineEnding},
RsaPrivateKey, RsaPublicKey,
};
use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
use tokio::{fs, io};
pub mod jwt_numeric_date;
const RSA_BITS: usize = 4096;
const KEYS_DIR: &str = "./keys";
const CUR_PRIV: &str = "cur_priv.pem";
const CUR_PUB: &str = "cur_pub.pem";
const PRE_PRIV: &str = "pre_priv.pem";
const PRE_PUB: &str = "pre_pub.pem";
#[derive(Clone)]
pub struct JWTSecretManager {
current: RSAKeyPair,
previous: Option<RSAKeyPair>,
}
#[derive(Debug, Error)]
pub enum JWTSecretManagerError {
#[error("Failed to generatie new key pair: {0}")]
KeyGeneration(rsa::Error),
#[error("Failed to save pem files to file system: {0}")]
KeySave(io::Error),
#[error("Failed to create the directory to store keys: {0}")]
KeysDir(io::Error),
#[error("Failed to read existing keys from file system: {0}")]
KeysRead(io::Error),
#[error("Failed to encode a new JWT: {0}")]
EncodeFailed(jsonwebtoken::errors::Error),
}
#[derive(Debug, PartialEq)]
pub enum ValidationOutcome<T>
where
T: Serialize,
for<'de> T: Deserialize<'de>,
{
Ok(T),
Outdated(T, String),
Error(jsonwebtoken::errors::Error),
Unauthorized,
}
impl<T> From<jsonwebtoken::errors::Error> for ValidationOutcome<T>
where
T: Serialize,
for<'de> T: Deserialize<'de>,
{
fn from(value: jsonwebtoken::errors::Error) -> Self {
ValidationOutcome::Error(value)
}
}
impl JWTSecretManager {
pub async fn init() -> Result<Self, JWTSecretManagerError> {
// Check if we have any keys
let keys_dir = Path::new(KEYS_DIR);
if !keys_dir.exists() {
fs::create_dir(KEYS_DIR)
.await
.map_err(JWTSecretManagerError::KeysDir)?;
}
let current = if !keys_dir.join(CUR_PUB).exists() || !keys_dir.join(CUR_PRIV).exists() {
let keys = RSAKeyPair::generate_new()
.await
.map_err(JWTSecretManagerError::KeyGeneration)?;
keys.save_pem_files(keys_dir, CUR_PRIV, CUR_PUB)
.await
.map_err(JWTSecretManagerError::KeySave)?;
keys
} else {
let public = fs::read_to_string(keys_dir.join(CUR_PUB))
.await
.map_err(JWTSecretManagerError::KeysRead)?;
let private = fs::read_to_string(keys_dir.join(CUR_PRIV))
.await
.map_err(JWTSecretManagerError::KeysRead)?;
RSAKeyPair {
private: Zeroizing::new(private),
public,
}
};
let previous = if !keys_dir.join(PRE_PUB).exists() || !keys_dir.join(PRE_PRIV).exists() {
None
} else {
let public = fs::read_to_string(keys_dir.join(PRE_PUB))
.await
.map_err(JWTSecretManagerError::KeysRead)?;
let private = fs::read_to_string(keys_dir.join(PRE_PRIV))
.await
.map_err(JWTSecretManagerError::KeysRead)?;
Some(RSAKeyPair {
private: Zeroizing::new(private),
public,
})
};
Ok(JWTSecretManager { current, previous })
}
pub async fn rotate(&mut self) -> Result<(), JWTSecretManagerError> {
self.previous = Some(self.current.clone());
self.current
.save_pem_files(KEYS_DIR, PRE_PRIV, PRE_PUB)
.await
.map_err(JWTSecretManagerError::KeySave)?;
self.current = RSAKeyPair::generate_new()
.await
.map_err(JWTSecretManagerError::KeyGeneration)?;
self.current
.save_pem_files(KEYS_DIR, CUR_PRIV, CUR_PUB)
.await
.map_err(JWTSecretManagerError::KeySave)?;
Ok(())
}
pub async fn decode<T>(&self, token: &str) -> ValidationOutcome<T>
where
T: Serialize + Clone,
for<'de> T: Deserialize<'de>,
{
if let Ok(claim) = self.current.verify_jwt(token).await {
return ValidationOutcome::Ok(claim);
}
match &self.previous {
Some(k) => {
if let Ok(claim) = k.verify_jwt::<T>(token).await {
let new_token = match self.current.encode(&claim).await {
Ok(t) => t,
Err(e) => return ValidationOutcome::Error(e),
};
return ValidationOutcome::Outdated(claim, new_token);
}
}
None => (),
}
ValidationOutcome::Unauthorized
}
pub async fn encode_new<T>(&self, claim: &T) -> Result<String, JWTSecretManagerError>
where
T: Serialize + Clone,
for<'de> T: Deserialize<'de>,
{
self.current
.encode(&claim)
.await
.map_err(JWTSecretManagerError::EncodeFailed)
}
}
#[derive(Clone)]
pub struct RSAKeyPair {
private: Zeroizing<String>,
public: String,
}
impl RSAKeyPair {
async fn generate_new() -> Result<RSAKeyPair, rsa::Error> {
let mut rng = rand::thread_rng();
let private_key = RsaPrivateKey::new(&mut rng, RSA_BITS).expect("failed to generate a key");
let public_key = RsaPublicKey::from(&private_key);
let private = private_key.to_pkcs8_pem(LineEnding::LF)?;
let public = public_key.to_public_key_pem(LineEnding::LF).unwrap(); // This is infaillible?
Ok(RSAKeyPair { private, public })
}
async fn save_pem_files(
&self,
path: impl AsRef<Path>,
private_name: &str,
public_name: &str,
) -> io::Result<()> {
// There's probably a better looking way to do this
let futures = vec![
fs::write(path.as_ref().join(public_name), &self.public),
fs::write(path.as_ref().join(private_name), &self.private),
];
let stream = futures::stream::iter(futures).buffer_unordered(2);
let results = stream.collect::<Vec<_>>().await;
for res in results {
res?;
}
Ok(())
}
async fn verify_jwt<T>(&self, token: &str) -> Result<T, jsonwebtoken::errors::Error>
where
for<'de> T: Deserialize<'de>,
{
match decode::<T>(
token,
&DecodingKey::from_rsa_pem(self.public.as_bytes())?,
&Validation::new(Algorithm::RS256),
) {
Ok(t) => Ok(t.claims),
Err(e) => Err(e),
}
}
async fn encode<T>(&self, claim: &T) -> Result<String, jsonwebtoken::errors::Error>
where
T: Serialize,
{
let secret = &EncodingKey::from_rsa_pem(self.private.as_bytes())?;
encode(&Header::new(Algorithm::RS256), claim, secret)
}
}
#[cfg(test)]
mod test {
use time::Duration;
use cookie::time::OffsetDateTime;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use crate::crypto::RSAKeyPair;
use crate::crypto::ValidationOutcome;
use super::jwt_numeric_date;
use super::JWTSecretManager;
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
struct Claim {
data: Vec<u8>,
#[serde(with = "jwt_numeric_date")]
exp: OffsetDateTime,
}
fn create_random_claim_data() -> Vec<u8> {
let mut bytes = [0u8; 16];
rand::thread_rng().fill_bytes(&mut bytes);
bytes.into()
}
#[tokio::test]
async fn generate_encode_and_decode_jwt_using_rsa() {
let rsa_keys = RSAKeyPair::generate_new()
.await
.expect("It should be possible to generate a new RSA key pair");
let claim = Claim {
data: create_random_claim_data(),
exp: OffsetDateTime::now_utc() + Duration::days(1),
};
let token = rsa_keys
.encode(&claim)
.await
.expect("It should be possible to encode a claim");
let decoded_claim = rsa_keys
.verify_jwt::<Claim>(&token)
.await
.expect("It should be possible to verify a token");
assert_eq!(decoded_claim.data, claim.data);
}
#[tokio::test]
async fn jwt_secret_manager_can_encode_and_decode() {
let jwt_secret_manager = JWTSecretManager::init()
.await
.expect("JWTSecretManager should be able to init");
let claim = Claim {
data: create_random_claim_data(),
exp: OffsetDateTime::now_utc() + Duration::days(1),
};
let token = jwt_secret_manager
.encode_new(&claim)
.await
.expect("It should be possible to encode a claim into a JWT");
match jwt_secret_manager.decode::<Claim>(&token).await {
ValidationOutcome::Ok(c) => assert_eq!(c.data, claim.data),
_ => panic!(),
}
}
#[tokio::test]
async fn jwt_secret_manager_can_rotate() {
let mut jwt_secret_manager = JWTSecretManager::init()
.await
.expect("JWTSecretManager should be able to init");
jwt_secret_manager
.rotate()
.await
.expect("JWTSecretManager should be able to rotate rsa keys");
}
}

@ -8,6 +8,8 @@ pub struct Env {
pub host: String, pub host: String,
#[serde(default = "port_default")] #[serde(default = "port_default")]
pub port: String, pub port: String,
#[serde(default = "client_url_default")]
pub client_url: String,
pub api_url: String, pub api_url: String,
pub mysql_user: String, pub mysql_user: String,
pub mysql_password: String, pub mysql_password: String,
@ -46,6 +48,10 @@ fn port_default() -> String {
"8000".to_string() "8000".to_string()
} }
fn client_url_default() -> String {
"http://localhost:8080".to_string()
}
fn mysql_port_default() -> String { fn mysql_port_default() -> String {
"3306".to_string() "3306".to_string()
} }
@ -108,7 +114,7 @@ impl Env {
#[derive(Debug)] #[derive(Debug)]
pub struct Config { pub struct Config {
pub server_address: SocketAddr, pub server_address: SocketAddr,
pub server_domain: String, pub client_url: String,
pub database_url: String, pub database_url: String,
pub max_connections: u32, pub max_connections: u32,
pub min_connections: u32, pub min_connections: u32,
@ -136,7 +142,7 @@ impl Config {
let mut config = Config { let mut config = Config {
server_address, server_address,
server_domain: env.api_url, client_url: env.client_url,
database_url, database_url,
max_connections: env.max_connections, max_connections: env.max_connections,
min_connections: env.min_connections, min_connections: env.min_connections,

@ -40,7 +40,7 @@ impl IntoResponse for AppError {
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Error in the in process transaction repo: {}", e), format!("Error in the in process transaction repo: {}", e),
), ),
AppError::NotFound(e) => (StatusCode::NOT_FOUND, format!("Not found error: {}", e)), AppError::NotFound(e) => (StatusCode::NOT_FOUND, format!("{e}")),
AppError::InternalServerError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e), AppError::InternalServerError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
AppError::Conflict(e) => (StatusCode::CONFLICT, e), AppError::Conflict(e) => (StatusCode::CONFLICT, e),

@ -10,16 +10,22 @@ extern crate argon2;
// External crates // External crates
use axum::{ use axum::{
extract::MatchedPath, extract::{MatchedPath, State},
headers::{authorization::Bearer, Authorization},
http::{HeaderValue, Method, Request, StatusCode}, http::{HeaderValue, Method, Request, StatusCode},
middleware::{from_fn_with_state, Next},
response::Response, response::Response,
routing::{get, post}, routing::{get, post},
Router, Router, TypedHeader,
}; };
use crypto::ValidationOutcome;
use http::header::{ACCESS_CONTROL_EXPOSE_HEADERS, AUTHORIZATION, CONTENT_TYPE};
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use tower_cookies::CookieManagerLayer; use serde::{Deserialize, Serialize};
use std::time::Duration; use std::{sync::Arc, time::Duration};
use time::OffsetDateTime;
use tokio::signal; use tokio::signal;
use tower::ServiceBuilder;
use tower_http::{classify::ServerErrorsFailureClass, cors::CorsLayer, trace::TraceLayer}; use tower_http::{classify::ServerErrorsFailureClass, cors::CorsLayer, trace::TraceLayer};
use tracing::{info, info_span, Span}; use tracing::{info, info_span, Span};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@ -29,6 +35,7 @@ use migration::MigratorTrait;
use route::{company, in_process_transaction, transaction}; use route::{company, in_process_transaction, transaction};
mod amf; mod amf;
mod crypto;
mod db; mod db;
mod env; mod env;
mod error; mod error;
@ -36,7 +43,14 @@ mod model;
mod repo; mod repo;
mod route; mod route;
mod task; mod task;
use crate::{route::user, task::run_tasks}; use crate::{
crypto::{jwt_numeric_date, JWTSecretManager},
route::{
authenticated::{get_profile, is_authenticated},
user,
},
task::run_tasks,
};
// Module imports // Module imports
use env::Config; use env::Config;
@ -53,6 +67,66 @@ async fn fallback() -> (StatusCode, &'static str) {
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db: DatabaseConnection, pub db: DatabaseConnection,
pub jwt_secret_manager: Arc<JWTSecretManager>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct UserJWTClaim {
pub user_id: i32,
pub username: String,
#[serde(with = "jwt_numeric_date")]
pub exp: OffsetDateTime,
}
async fn authenticator<B>(
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
State(state): State<AppState>,
mut request: Request<B>,
next: Next<B>,
) -> Result<Response, StatusCode> {
let mut new_token_opt = None;
let authenticated = match state
.jwt_secret_manager
.decode::<UserJWTClaim>(auth.token())
.await
{
ValidationOutcome::Ok(token_data) => {
request.extensions_mut().insert(token_data);
true
}
ValidationOutcome::Unauthorized => false,
ValidationOutcome::Outdated(token_data, t) => {
request.extensions_mut().insert(token_data);
new_token_opt = Some(t);
true
}
ValidationOutcome::Error(e) => {
error!("Error in authentication layer: {}", e);
false
}
};
let mut response = next.run(request).await;
if authenticated {
if let Some(token) = new_token_opt {
// If for some reason the token cannot be put in a header, we just skip sending it
if let Ok(header_value) = HeaderValue::from_str(&token) {
let headers = response.headers_mut();
headers.insert("x-new-token", header_value);
headers.insert(
ACCESS_CONTROL_EXPOSE_HEADERS,
HeaderValue::from_str("x-new-token").unwrap(),
);
} else {
warn!("Failed to put an updated token in a response header");
}
}
Ok(response)
} else {
Err(StatusCode::UNAUTHORIZED)
}
} }
#[tokio::main] #[tokio::main]
@ -65,12 +139,61 @@ pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
info!("Initializing JWT secret manager");
let jwt_secret_manager = match JWTSecretManager::init().await {
Ok(j) => {
info!("JWTSecretManager initialized");
j
}
Err(e) => {
error!("Error initializing the the JWT secret manager: {}", e);
Err(e)?
}
};
let shared_state = AppState { let shared_state = AppState {
db: db::init().await?, db: db::init().await?,
jwt_secret_manager: Arc::new(jwt_secret_manager),
}; };
let _ = migration::Migrator::up(&shared_state.db, None).await; let _ = migration::Migrator::up(&shared_state.db, None).await;
let trace_layer = TraceLayer::new_for_http()
.make_span_with(|request: &Request<_>| {
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
info_span!(
"http_request",
method = ?request.method(),
full_path = ?request.uri(),
matched_path,
)
})
.on_request(|_request: &Request<_>, _span: &Span| {
info!("New request");
})
.on_response(|response: &Response, latency: Duration, _span: &Span| {
info!(
"Response, status {}, time {}ms",
response.status(),
latency.as_millis()
);
})
.on_failure(
|error: ServerErrorsFailureClass, latency: Duration, _span: &Span| {
error!("There was an error answering this request, the server nonetheless answered in {}ms, error: {}", latency.as_millis(), error);
},
);
let authenticated_routes = Router::<AppState, _>::new()
.route("/is_authenticated", get(is_authenticated))
.route("/profile", get(get_profile))
.layer(from_fn_with_state(shared_state.clone(), authenticator))
.with_state(shared_state.clone());
let app = Router::new() let app = Router::new()
.route("/company", get(company::get_all)) .route("/company", get(company::get_all))
.route("/company/:name", get(company::get_by_name)) .route("/company/:name", get(company::get_by_name))
@ -99,53 +222,19 @@ pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
"/in_process_transaction/retry_all", "/in_process_transaction/retry_all",
get(in_process_transaction::retry_all), get(in_process_transaction::retry_all),
) )
.route( .route("/user/login", post(user::login))
"/user/login", .route("/user/register", post(user::register))
post(user::login), .nest("/auth", authenticated_routes)
)
.route(
"/user/register",
post(user::register),
)
.with_state(shared_state.clone())
.fallback(fallback) .fallback(fallback)
.layer( .layer(
TraceLayer::new_for_http() ServiceBuilder::new().layer(trace_layer).layer(
.make_span_with(|request: &Request<_>| {
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
info_span!(
"http_request",
method = ?request.method(),
full_path = ?request.uri(),
matched_path,
)
})
.on_request(|_request: &Request<_>, _span: &Span| {
info!("New request");
})
.on_response(|response: &Response, latency: Duration, _span: &Span| {
info!(
"Response, status {}, time {}ms",
response.status(),
latency.as_millis()
);
})
.on_failure(
|error: ServerErrorsFailureClass, latency: Duration, _span: &Span| {
error!("There was an error answering this request, the server nonetheless answered in {}ms, error: {}", latency.as_millis(), error);
},
),
)
.layer(CookieManagerLayer::new())
.layer(
CorsLayer::new() CorsLayer::new()
.allow_origin("*".parse::<HeaderValue>().unwrap()) .allow_origin("*".parse::<HeaderValue>().unwrap())
.allow_methods([Method::GET]) .allow_methods([Method::GET, Method::POST])
); .allow_headers([CONTENT_TYPE, AUTHORIZATION]),
),
)
.with_state(shared_state.clone());
// Run tasks // Run tasks
tokio::task::spawn(async move { run_tasks(&shared_state.db).await }); tokio::task::spawn(async move { run_tasks(&shared_state.db).await });

@ -8,7 +8,9 @@ use serde::{Deserialize, Serialize};
pub struct Model { pub struct Model {
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub email: String, #[sea_orm(unique)]
pub email: Option<String>,
#[sea_orm(unique)]
pub name: String, pub name: String,
pub password: String, pub password: String,
} }

@ -6,7 +6,7 @@ use crate::model;
#[derive(Debug, PartialEq, Clone, DeriveIntoActiveModel, Serialize, Deserialize)] #[derive(Debug, PartialEq, Clone, DeriveIntoActiveModel, Serialize, Deserialize)]
pub struct NewUser { pub struct NewUser {
pub email: String, pub email: Option<String>,
pub name: String, pub name: String,
pub password: String, pub password: String,
} }

@ -0,0 +1,46 @@
use axum::{extract::State, Extension, Json};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use crate::{error::AppError, model, AppState, UserJWTClaim};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserProfile {
email: Option<String>,
name: String,
}
impl From<model::user::Model> for UserProfile {
fn from(value: model::user::Model) -> Self {
UserProfile {
email: value.email,
name: value.name,
}
}
}
pub async fn is_authenticated() -> Result<(), AppError> {
Ok(())
}
pub async fn get_profile(
Extension(token_data): Extension<UserJWTClaim>,
State(state): State<AppState>,
) -> Result<Json<UserProfile>, AppError> {
let db = &state.db;
let user_opt = model::user::Entity::find()
.filter(model::user::Column::Id.eq(token_data.user_id))
.one(db)
.await?;
if let Some(user) = user_opt {
return Ok(Json(UserProfile {
email: user.email,
name: user.name,
}));
}
Err(AppError::NotFound(
"Authenticated user does not exist".to_string(),
))
}

@ -2,6 +2,7 @@ use std::{fmt, str::FromStr};
use serde::{de, Deserialize, Deserializer}; use serde::{de, Deserialize, Deserializer};
pub mod authenticated;
pub mod company; pub mod company;
pub mod in_process_transaction; pub mod in_process_transaction;
pub mod transaction; pub mod transaction;

@ -1,12 +1,11 @@
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use cookie::{time::Duration, Cookie, SameSite};
use jsonwebtoken::{encode, EncodingKey, Header};
use rand::RngCore; use rand::RngCore;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tower_cookies::Cookies; use time::OffsetDateTime;
use crate::{error::AppError, model, repo::user::NewUser, AppState, CONFIG}; use crate::UserJWTClaim;
use crate::{error::AppError, model, repo::user::NewUser, AppState};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UserLoginBody { pub struct UserLoginBody {
@ -14,16 +13,15 @@ pub struct UserLoginBody {
pub password: String, pub password: String,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize)]
pub struct JWTClaim { pub struct LoginResponse {
pub username: String, pub token: String,
} }
pub async fn login( pub async fn login(
cookies: Cookies,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<UserLoginBody>, Json(payload): Json<UserLoginBody>,
) -> Result<(), AppError> { ) -> Result<Json<LoginResponse>, AppError> {
let db = &state.db; let db = &state.db;
let user_opt = model::user::Entity::find() let user_opt = model::user::Entity::find()
.filter(model::user::Column::Name.eq(payload.name)) .filter(model::user::Column::Name.eq(payload.name))
@ -33,7 +31,9 @@ pub async fn login(
if user_opt.is_none() { if user_opt.is_none() {
// To prevent timing attacks, we use the same verify function on a known password // To prevent timing attacks, we use the same verify function on a known password
argon2::verify_encoded("$argon2i$v=19$m=4096,t=3,p=1$CXr/AgSDawghR+GmOhM0wQ$4k2TCyoqkh/YaK9mh6uEa0eRZ/CIx3bfzJs5UnCcKjw", b"1234").unwrap(); argon2::verify_encoded("$argon2i$v=19$m=4096,t=3,p=1$CXr/AgSDawghR+GmOhM0wQ$4k2TCyoqkh/YaK9mh6uEa0eRZ/CIx3bfzJs5UnCcKjw", b"1234").unwrap();
return Err(AppError::NotFound("User not found".to_string())); return Err(AppError::NotFound(
"User does not exist. Consider registering.".to_string(),
));
} }
let user = user_opt.unwrap(); let user = user_opt.unwrap();
@ -41,7 +41,9 @@ pub async fn login(
let valid = let valid =
argon2::verify_encoded(&user.password, payload.password.as_bytes()).map_err(|e| { argon2::verify_encoded(&user.password, payload.password.as_bytes()).map_err(|e| {
error!("Error verifying the password for user {}: {}", user.name, e); error!("Error verifying the password for user {}: {}", user.name, e);
AppError::InternalServerError("There was an error verifying authentication".to_string()) AppError::InternalServerError(
"There was an error verifying authentication.".to_string(),
)
})?; })?;
if !valid { if !valid {
@ -49,32 +51,24 @@ pub async fn login(
} }
// Generate a JWT and store it as a same site cookie // Generate a JWT and store it as a same site cookie
let claim = JWTClaim { let claim = UserJWTClaim {
username: user.name, user_id: user.id,
username: user.name.clone(),
exp: OffsetDateTime::now_utc() + time::Duration::days(5),
}; };
let token_str = encode( let token = state
&Header::default(), .jwt_secret_manager
&claim, .encode_new(&claim)
&EncodingKey::from_secret(b"some-secret"), .await
)
.map_err(|e| { .map_err(|e| {
error!("Failed to encode a JWT: {}", e); error!("Failed to encode a new JWT for user {}: {}", user.name, e);
AppError::InternalServerError("There was an error verifying authentication".to_string()) AppError::InternalServerError(
"There was an error while encoding the authorization token".to_string(),
)
})?; })?;
let cookie = Cookie::build("auth", token_str) Ok(Json(LoginResponse { token }))
.domain(CONFIG.server_domain.clone())
.path("/")
.same_site(SameSite::Strict)
.secure(true)
.http_only(true)
.max_age(Duration::days(4))
.finish();
cookies.add(cookie);
Ok(())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -89,18 +83,18 @@ pub async fn register(
Json(payload): Json<UserRegisterBody>, Json(payload): Json<UserRegisterBody>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let db = &state.db; let db = &state.db;
let user_opt = model::user::Entity::find() let mut filter = model::user::Column::Name.eq(&payload.name);
.filter( let mut email = Some(payload.email.to_string());
model::user::Column::Name if !payload.email.is_empty() {
.eq(&payload.name) filter = filter.or(model::user::Column::Email.eq(&payload.email));
.or(model::user::Column::Email.eq(&payload.email)), } else {
) email = None;
.one(db) }
.await?; let user_opt = model::user::Entity::find().filter(filter).one(db).await?;
if user_opt.is_some() { if user_opt.is_some() {
return Err(AppError::Conflict( return Err(AppError::Conflict(
"The username or email is already in use".to_string(), "The username or email is already in use.".to_string(),
)); ));
} }
@ -115,7 +109,7 @@ pub async fn register(
})?; })?;
let new_user = NewUser { let new_user = NewUser {
email: payload.email, email,
name: payload.name, name: payload.name,
password: pass_hash, password: pass_hash,
}; };

Loading…
Cancel
Save