From daf472fd3e9de753a3059b8b061351901e7f680e Mon Sep 17 00:00:00 2001 From: eraden Date: Sat, 5 Aug 2023 22:20:23 +0200 Subject: [PATCH] Register and validations --- Cargo.lock | 117 +++++++++++++++- crates/oswilno-session/Cargo.toml | 1 + crates/oswilno-session/src/lib.rs | 127 +++++++++++++----- .../templates/register/partial.html | 45 +++++-- crates/oswilno-view/Cargo.toml | 1 + crates/oswilno-view/src/filters.rs | 2 +- crates/oswilno-view/src/lib.rs | 47 +++++++ crates/web-assets/assets/app.js | 9 ++ 8 files changed, 306 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1557ee1..f17db16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -857,6 +857,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1039,6 +1048,16 @@ dependencies = [ "bytes", ] +[[package]] +name = "card-validate" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6894706eb61c98c868965ca508ea1a13c68d293acb68a85db1ba327e7c55b31c" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "cc" version = "1.0.79" @@ -1601,6 +1620,34 @@ dependencies = [ "slab", ] +[[package]] +name = "garde" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9d6ea59620728a8e9fb67bba68dbd88bff7744de0c7149027612334ed6a40b" +dependencies = [ + "card-validate", + "garde_derive", + "idna 0.3.0", + "once_cell", + "phonenumber", + "regex", + "serde", + "url", +] + +[[package]] +name = "garde_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d9df466e06c7de0744e5f14048ba5331d7a4d31e98542046d869e953fc3420f" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.28", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1836,6 +1883,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.4.0" @@ -2062,6 +2119,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "matchers" version = "0.1.0" @@ -2215,6 +2281,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "oncemutex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d11de466f4a3006fe8a5e7ec84e93b79c70cb992ae0aa0eb631ad2df8abfe2" + [[package]] name = "opaque-debug" version = "0.3.0" @@ -2323,6 +2395,7 @@ dependencies = [ "askama_actix", "autometrics", "futures", + "garde", "jsonwebtoken", "oswilno-contract", "oswilno-view", @@ -2341,6 +2414,7 @@ name = "oswilno-view" version = "0.1.0" dependencies = [ "askama", + "garde", "tracing", ] @@ -2557,6 +2631,26 @@ dependencies = [ "uncased", ] +[[package]] +name = "phonenumber" +version = "0.3.2+8.13.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34749f64ea9d76f10cdc8a859588b57775f59177c7dd91f744d620bd62982d6f" +dependencies = [ + "bincode", + "either", + "fnv", + "itertools", + "lazy_static", + "nom", + "quick-xml", + "regex", + "regex-cache", + "serde", + "serde_derive", + "thiserror", +] + [[package]] name = "pin-project-lite" version = "0.2.10" @@ -2671,6 +2765,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5e73202a820a31f8a0ee32ada5e21029c81fd9e3ebf668a40832e4219d9d1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.32" @@ -2777,6 +2880,18 @@ dependencies = [ "regex-syntax 0.7.4", ] +[[package]] +name = "regex-cache" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7b62d69743b8b94f353b6b7c3deb4c5582828328bcb8d5fedf214373808793" +dependencies = [ + "lru-cache", + "oncemutex", + "regex", + "regex-syntax 0.6.29", +] + [[package]] name = "regex-syntax" version = "0.6.29" @@ -3950,7 +4065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" dependencies = [ "form_urlencoded", - "idna", + "idna 0.4.0", "percent-encoding", ] diff --git a/crates/oswilno-session/Cargo.toml b/crates/oswilno-session/Cargo.toml index 9017826..99076d3 100644 --- a/crates/oswilno-session/Cargo.toml +++ b/crates/oswilno-session/Cargo.toml @@ -12,6 +12,7 @@ askama = { version = "0.12.0", features = ["serde", "with-actix-web", "comrak", askama_actix = { version = "0.14.0" } autometrics = { version = "0.5.0", features = ["tracing", "tracing-subscriber", "thiserror"] } futures = { version = "0.3.28", features = ["thread-pool"] } +garde = { version = "0.14.0", features = ["derive"] } jsonwebtoken = "8.3.0" oswilno-contract = { path = "../oswilno-contract" } oswilno-view = { path = "../oswilno-view" } diff --git a/crates/oswilno-session/src/lib.rs b/crates/oswilno-session/src/lib.rs index 67b7c55..84efea0 100644 --- a/crates/oswilno-session/src/lib.rs +++ b/crates/oswilno-session/src/lib.rs @@ -9,6 +9,7 @@ use autometrics::autometrics; use futures::channel::{mpsc, mpsc::Sender}; use futures::stream::Stream; use futures::SinkExt; +use garde::Validate; use jsonwebtoken::*; use oswilno_view::{Lang, TranslationStorage}; use ring::rand::SystemRandom; @@ -99,11 +100,17 @@ impl SessionConfigurator { .add("Sign in", "Logowanie") .add("Sign up", "Rejestracja") .add("Bad credentials", "Złe dane uwierzytelniające") - .add("Login already taken", "Login jest zajęty") + .add("is taken", "jest zajęty") + .add("is not strong enough", "jest za słabe") + .add( + "length is lower than 8", + "długość jest mniejsza niż 8 znaków", + ) .add( "Login or email already taken", "Login lub adres e-mail jest zajęty", ) + .add("Password", "Hasło") .add("Submit", "Wyślij") .done(); } @@ -275,19 +282,16 @@ async fn logout( Ok(HttpResponse::Ok().json(EmptyResponse {})) } -#[derive(Debug, Default, Serialize, Deserialize, Clone, Eq, PartialEq)] +#[derive(Debug, Default, Serialize, Deserialize, Clone, Eq, PartialEq, garde::Validate)] +#[garde(context(RegisterContext))] struct AccountInfo { - #[serde(skip)] - errors: Vec, - login: String, - #[serde(skip)] - login_errors: Vec, + #[garde(length(min = 4, max = 30), custom(is_login_free))] + #[serde(rename = "login")] + input_login: String, + #[garde(email, custom(is_email_free))] email: String, - #[serde(skip)] - email_errors: Vec, + #[garde(length(min = 8, max = 50), custom(is_strong_password))] password: String, - #[serde(skip)] - password_errors: Vec, } #[derive(askama_actix::Template)] @@ -296,6 +300,7 @@ struct RegisterTemplate { form: AccountInfo, t: Arc, lang: Lang, + errors: oswilno_view::Errors, } #[get("/register")] @@ -304,6 +309,7 @@ async fn register_view(t: Data) -> RegisterTemplate { form: AccountInfo::default(), t: t.into_inner(), lang: Lang::Pl, + errors: oswilno_view::Errors::default(), } } #[get("/p/register")] @@ -312,6 +318,7 @@ async fn register_partial_view(t: Data) -> RegisterTemplate form: AccountInfo::default(), t: t.into_inner(), lang: Lang::Pl, + errors: oswilno_view::Errors::default(), } } @@ -321,6 +328,7 @@ struct RegisterPartialTemplate { form: AccountInfo, t: Arc, lang: Lang, + errors: oswilno_view::Errors, } #[autometrics] @@ -331,14 +339,16 @@ async fn register( t: Data, ) -> Result { let t = t.into_inner(); + let mut errors = oswilno_view::Errors::default(); Ok( - match register_internal(db.into_inner(), payload.into_inner(), t.clone()).await { + match register_internal(db.into_inner(), payload.into_inner(), &mut errors).await { Ok(res) => res, Err(p) => HttpResponse::BadRequest().body( RegisterPartialTemplate { form: p, t, lang: Lang::Pl, + errors, } .render() .unwrap(), @@ -347,14 +357,85 @@ async fn register( ) } +struct RegisterContext { + login_taken: bool, + email_taken: bool, +} + +fn is_email_free(_value: &str, context: &RegisterContext) -> garde::Result { + if context.email_taken { + return Err(garde::Error::new("is taken")); + } + Ok(()) +} +fn is_login_free(_value: &str, context: &RegisterContext) -> garde::Result { + if context.login_taken { + return Err(garde::Error::new("is taken")); + } + Ok(()) +} + +static WEAK_PASS: &str = "is not strong enough"; +fn is_strong_password(value: &str, _context: &RegisterContext) -> garde::Result { + if !(8..50).contains(&value.len()) { + return Err(garde::Error::new(WEAK_PASS)); + } + let mut num = false; + let mut low = false; + let mut up = false; + let mut spec = false; + for c in value.chars() { + if num && low && up && spec { + return Ok(()); + } + num = num || c.is_numeric(); + low = low || c.is_lowercase(); + up = up || c.is_uppercase(); + spec = spec || !c.is_alphanumeric(); + } + + return Err(garde::Error::new(WEAK_PASS)); +} + async fn register_internal( db: Arc, - mut p: AccountInfo, - t: Arc, + p: AccountInfo, + errors: &mut oswilno_view::Errors, ) -> Result { use oswilno_contract::accounts::*; use sea_orm::*; + let query_result = db + .query_one(sea_orm::Statement::from_sql_and_values( + sea_orm::DbBackend::Postgres, + "select login = $1 as login_taken, email = $2 as email_taken from accounts", + [p.input_login.clone().into(), p.email.clone().into()], + )) + .await + .map_err(|e| { + tracing::error!("{e}"); + errors.push_global("Something went wrong"); + p.clone() + })?; + let (login_taken, email_taken) = if let Some(query_result) = query_result { + let Ok((login_taken, email_taken)): Result<(bool,bool), _> = query_result.try_get_many("", &["login_taken".into(), "email_taken".into()]) else { + tracing::warn!("Failed to fetch fields from query result while checking if account info exists in db"); + errors.push_global("Something went wrong"); + return Err(p); + }; + (login_taken, email_taken) + } else { + (false, false) + }; + + if let Err(e) = p.validate(&RegisterContext { + login_taken, + email_taken, + }) { + errors.consume_garde(e); + } + tracing::warn!("{errors:#?}"); + let pass = match hashing::encrypt(p.password.as_str()) { Ok(p) => p, Err(e) => { @@ -362,22 +443,9 @@ async fn register_internal( return Ok(HttpResponse::InternalServerError().body("")); } }; - - match Entity::find() - .filter(Column::Login.eq(&p.login).or(Column::Email.eq(&p.email))) - .one(&*db) - .await - { - Ok(None) | Err(_) => { - p.login_errors.push("Login already taken".into()); - return Err(p); - } - _ => (), - }; - let model = match (ActiveModel { id: NotSet, - login: Set(p.login.to_string()), + login: Set(p.input_login.to_string()), email: Set(p.email.to_string()), pass_hash: Set(pass), ..Default::default() @@ -388,8 +456,7 @@ async fn register_internal( Ok(model) => model, Err(e) => { tracing::warn!("{e}"); - p.login_errors - .push(t.to_lang(Lang::Pl, "Login or email already taken")); + errors.push_global("Login or email already taken"); return Err(p); } }; diff --git a/crates/oswilno-session/templates/register/partial.html b/crates/oswilno-session/templates/register/partial.html index 97f4fb9..c725de6 100644 --- a/crates/oswilno-session/templates/register/partial.html +++ b/crates/oswilno-session/templates/register/partial.html @@ -1,26 +1,49 @@
- {% for error in form.errors %} + {% for error in errors.global() %} {{error|t(lang,t)}} {% endfor %}
- - - {% for error in form.login_errors %} - {{error|t(lang,t)}} + + + {% for error in errors.field("input_login") %} + {{error|t(lang,t)}} {% endfor %}
- - - {% for error in form.email_errors %} - {{error|t(lang,t)}} + + + {% for error in errors.field("email") %} + {{error|t(lang,t)}} {% endfor %}
- - + + + {% for error in errors.field("password") %} + {{error|t(lang,t)}} + {% endfor %}
( lang: &Lang, t: &Arc, ) -> Result { - tracing::debug!("translating {s:?} to lang {lang:?} with {t:?}"); + // tracing::debug!("translating {s:?} to lang {lang:?} with {t:?}"); Ok(t.to_lang(*lang, &s.to_string())) } diff --git a/crates/oswilno-view/src/lib.rs b/crates/oswilno-view/src/lib.rs index 85cd4b4..7b87715 100644 --- a/crates/oswilno-view/src/lib.rs +++ b/crates/oswilno-view/src/lib.rs @@ -3,6 +3,53 @@ use std::sync::{Arc, RwLock}; pub mod filters; +#[derive(Debug, Clone, Default)] +pub struct Errors { + errors: Vec, + field_errors: Vec<(String, garde::Error)>, +} + +impl Errors { + pub fn push_global>(&mut self, s: S) { + self.errors.push(s.into()); + } + + pub fn push_field>(&mut self, field: S, error: &'static str) { + self.field_errors + .push((field.into(), garde::Error::new(error))); + } + + pub fn consume_garde(&mut self, g: garde::Errors) { + self.field_errors = g.flatten(); + } + + pub fn global(&self) -> &[String] { + &self.errors + } + + pub fn field(&self, name: &str) -> Vec { + self.field_errors + .iter() + .filter_map(|(field, error)| { + if field.contains(name) { + Some(error.message.clone().into()) + } else { + None + } + }) + .collect() + } +} + +impl From for Errors { + fn from(value: garde::Errors) -> Self { + Errors { + errors: vec![], + field_errors: value.flatten(), + } + } +} + #[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)] pub enum Lang { Pl, diff --git a/crates/web-assets/assets/app.js b/crates/web-assets/assets/app.js index f1b36cf..1c28e39 100644 --- a/crates/web-assets/assets/app.js +++ b/crates/web-assets/assets/app.js @@ -1,3 +1,12 @@ import './elements/oswilno-price.js'; import './elements/oswilno-error.js'; import("https://unpkg.com/htmx.org@1.9.4/dist/htmx.min.js"); + +document.body.addEventListener('htmx:beforeOnLoad', function (evt) { + const status = evt.detail.xhr.status; + if (status === 422 || status === 400) { + evt.detail.shouldSwap = true; + evt.detail.isError = false; + } +}); +