Register and validations

This commit is contained in:
eraden 2023-08-05 22:20:23 +02:00
parent 36325a2de2
commit 1c6dabcd77
8 changed files with 306 additions and 43 deletions

117
Cargo.lock generated
View File

@ -857,6 +857,15 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -1039,6 +1048,16 @@ dependencies = [
"bytes", "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]] [[package]]
name = "cc" name = "cc"
version = "1.0.79" version = "1.0.79"
@ -1601,6 +1620,34 @@ dependencies = [
"slab", "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]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@ -1836,6 +1883,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 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]] [[package]]
name = "idna" name = "idna"
version = "0.4.0" version = "0.4.0"
@ -2062,6 +2119,15 @@ dependencies = [
"value-bag", "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]] [[package]]
name = "matchers" name = "matchers"
version = "0.1.0" version = "0.1.0"
@ -2215,6 +2281,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "oncemutex"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d11de466f4a3006fe8a5e7ec84e93b79c70cb992ae0aa0eb631ad2df8abfe2"
[[package]] [[package]]
name = "opaque-debug" name = "opaque-debug"
version = "0.3.0" version = "0.3.0"
@ -2323,6 +2395,7 @@ dependencies = [
"askama_actix", "askama_actix",
"autometrics", "autometrics",
"futures", "futures",
"garde",
"jsonwebtoken", "jsonwebtoken",
"oswilno-contract", "oswilno-contract",
"oswilno-view", "oswilno-view",
@ -2341,6 +2414,7 @@ name = "oswilno-view"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"askama", "askama",
"garde",
"tracing", "tracing",
] ]
@ -2557,6 +2631,26 @@ dependencies = [
"uncased", "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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.10" version = "0.2.10"
@ -2671,6 +2765,15 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.32" version = "1.0.32"
@ -2777,6 +2880,18 @@ dependencies = [
"regex-syntax 0.7.4", "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]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.6.29" version = "0.6.29"
@ -3950,7 +4065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb"
dependencies = [ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna 0.4.0",
"percent-encoding", "percent-encoding",
] ]

View File

@ -11,6 +11,7 @@ argon2 = "0.5.1"
askama_actix = { version = "0.14.0" } askama_actix = { version = "0.14.0" }
autometrics = { version = "0.5.0", features = ["tracing", "tracing-subscriber", "thiserror"] } autometrics = { version = "0.5.0", features = ["tracing", "tracing-subscriber", "thiserror"] }
futures = { version = "0.3.28", features = ["thread-pool"] } futures = { version = "0.3.28", features = ["thread-pool"] }
garde = { version = "0.14.0", features = ["derive"] }
jsonwebtoken = "8.3.0" jsonwebtoken = "8.3.0"
oswilno-contract = { path = "../oswilno-contract" } oswilno-contract = { path = "../oswilno-contract" }
oswilno-view = { path = "../oswilno-view" } oswilno-view = { path = "../oswilno-view" }

View File

@ -9,6 +9,7 @@ use autometrics::autometrics;
use futures::channel::{mpsc, mpsc::Sender}; use futures::channel::{mpsc, mpsc::Sender};
use futures::stream::Stream; use futures::stream::Stream;
use futures::SinkExt; use futures::SinkExt;
use garde::Validate;
use jsonwebtoken::*; use jsonwebtoken::*;
use oswilno_view::{Lang, TranslationStorage}; use oswilno_view::{Lang, TranslationStorage};
use ring::rand::SystemRandom; use ring::rand::SystemRandom;
@ -99,11 +100,17 @@ impl SessionConfigurator {
.add("Sign in", "Logowanie") .add("Sign in", "Logowanie")
.add("Sign up", "Rejestracja") .add("Sign up", "Rejestracja")
.add("Bad credentials", "Złe dane uwierzytelniające") .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( .add(
"Login or email already taken", "Login or email already taken",
"Login lub adres e-mail jest zajęty", "Login lub adres e-mail jest zajęty",
) )
.add("Password", "Hasło")
.add("Submit", "Wyślij") .add("Submit", "Wyślij")
.done(); .done();
} }
@ -275,19 +282,16 @@ async fn logout(
Ok(HttpResponse::Ok().json(EmptyResponse {})) 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 { struct AccountInfo {
#[serde(skip)] #[garde(length(min = 4, max = 30), custom(is_login_free))]
errors: Vec<String>, #[serde(rename = "login")]
login: String, input_login: String,
#[serde(skip)] #[garde(email, custom(is_email_free))]
login_errors: Vec<String>,
email: String, email: String,
#[serde(skip)] #[garde(length(min = 8, max = 50), custom(is_strong_password))]
email_errors: Vec<String>,
password: String, password: String,
#[serde(skip)]
password_errors: Vec<String>,
} }
#[derive(askama_actix::Template)] #[derive(askama_actix::Template)]
@ -296,6 +300,7 @@ struct RegisterTemplate {
form: AccountInfo, form: AccountInfo,
t: Arc<TranslationStorage>, t: Arc<TranslationStorage>,
lang: Lang, lang: Lang,
errors: oswilno_view::Errors,
} }
#[get("/register")] #[get("/register")]
@ -304,6 +309,7 @@ async fn register_view(t: Data<TranslationStorage>) -> RegisterTemplate {
form: AccountInfo::default(), form: AccountInfo::default(),
t: t.into_inner(), t: t.into_inner(),
lang: Lang::Pl, lang: Lang::Pl,
errors: oswilno_view::Errors::default(),
} }
} }
#[get("/p/register")] #[get("/p/register")]
@ -312,6 +318,7 @@ async fn register_partial_view(t: Data<TranslationStorage>) -> RegisterTemplate
form: AccountInfo::default(), form: AccountInfo::default(),
t: t.into_inner(), t: t.into_inner(),
lang: Lang::Pl, lang: Lang::Pl,
errors: oswilno_view::Errors::default(),
} }
} }
@ -321,6 +328,7 @@ struct RegisterPartialTemplate {
form: AccountInfo, form: AccountInfo,
t: Arc<TranslationStorage>, t: Arc<TranslationStorage>,
lang: Lang, lang: Lang,
errors: oswilno_view::Errors,
} }
#[autometrics] #[autometrics]
@ -331,14 +339,16 @@ async fn register(
t: Data<oswilno_view::TranslationStorage>, t: Data<oswilno_view::TranslationStorage>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let t = t.into_inner(); let t = t.into_inner();
let mut errors = oswilno_view::Errors::default();
Ok( 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, Ok(res) => res,
Err(p) => HttpResponse::BadRequest().body( Err(p) => HttpResponse::BadRequest().body(
RegisterPartialTemplate { RegisterPartialTemplate {
form: p, form: p,
t, t,
lang: Lang::Pl, lang: Lang::Pl,
errors,
} }
.render() .render()
.unwrap(), .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( async fn register_internal(
db: Arc<DatabaseConnection>, db: Arc<DatabaseConnection>,
mut p: AccountInfo, p: AccountInfo,
t: Arc<TranslationStorage>, errors: &mut oswilno_view::Errors,
) -> Result<HttpResponse, AccountInfo> { ) -> Result<HttpResponse, AccountInfo> {
use oswilno_contract::accounts::*; use oswilno_contract::accounts::*;
use sea_orm::*; 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()) { let pass = match hashing::encrypt(p.password.as_str()) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
@ -362,22 +443,9 @@ async fn register_internal(
return Ok(HttpResponse::InternalServerError().body("")); 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 { let model = match (ActiveModel {
id: NotSet, id: NotSet,
login: Set(p.login.to_string()), login: Set(p.input_login.to_string()),
email: Set(p.email.to_string()), email: Set(p.email.to_string()),
pass_hash: Set(pass), pass_hash: Set(pass),
..Default::default() ..Default::default()
@ -388,8 +456,7 @@ async fn register_internal(
Ok(model) => model, Ok(model) => model,
Err(e) => { Err(e) => {
tracing::warn!("{e}"); tracing::warn!("{e}");
p.login_errors errors.push_global("Login or email already taken");
.push(t.to_lang(Lang::Pl, "Login or email already taken"));
return Err(p); return Err(p);
} }
}; };

View File

@ -1,26 +1,49 @@
<section id="main-view" class="min-h-screen flex items-center justify-center"> <section id="main-view" class="min-h-screen flex items-center justify-center">
<section class="max-w-md w-full p-6 bg-white rounded-lg shadow-lg"> <section class="max-w-md w-full p-6 bg-white rounded-lg shadow-lg">
{% for error in form.errors %} {% for error in errors.global() %}
<oswilno-error>{{error|t(lang,t)}}</oswilno-error> <oswilno-error>{{error|t(lang,t)}}</oswilno-error>
{% endfor %} {% endfor %}
<form hx-post="/register" hx-target="#main-view"> <form hx-post="/register" hx-target="#main-view">
<div class="mb-4"> <div class="mb-4">
<label for="login" class="block mb-2 text-sm text-gray-600">Login</label> <label for="login" class="block mb-2 text-sm text-gray-600">{{"Login"|t(lang,t)}}</label>
<input id="login" name="login" value="{{form.login}}" required class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500"/> <input
{% for error in form.login_errors %} id="login"
<oswilno-error>{{error|t(lang,t)}}</oswilno-error> name="login"
value="{{form.input_login}}"
required
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
{% for error in errors.field("input_login") %}
<oswilno-error class="mb-2 mt-2">{{error|t(lang,t)}}</oswilno-error>
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="email" class="block mb-2 text-sm text-gray-600">E-Mail</label> <label for="email" class="block mb-2 text-sm text-gray-600">{{"E-Mail"|t(lang,t)}}</label>
<input id="email" name="email" type="email" value="{{form.email}}" required class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500"/> <input
{% for error in form.email_errors %} id="email"
<oswilno-error>{{error|t(lang,t)}}</oswilno-error> name="email"
type="email"
value="{{form.email}}"
required
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
{% for error in errors.field("email") %}
<oswilno-error class="mb-2 mt-2">{{error|t(lang,t)}}</oswilno-error>
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="password" class="block mb-2 text-sm text-gray-600">Password</label> <label for="password" class="block mb-2 text-sm text-gray-600">{{"Password"|t(lang,t)}}</label>
<input id="password" name="password" type="password" required class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500"/> <input
id="password"
name="password"
type="password"
value="{{form.password}}"
required
class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
{% for error in errors.field("password") %}
<oswilno-error class="mb-2 mt-2">{{error|t(lang,t)}}</oswilno-error>
{% endfor %}
</div> </div>
<div class="mb-6"> <div class="mb-6">
<input <input

View File

@ -5,4 +5,5 @@ edition = "2021"
[dependencies] [dependencies]
askama = { version = "0.12.0", features = ["serde", "with-actix-web", "comrak", "mime"] } askama = { version = "0.12.0", features = ["serde", "with-actix-web", "comrak", "mime"] }
garde = { version = "0.14.0", features = ["derive"] }
tracing = "0.1.37" tracing = "0.1.37"

View File

@ -8,6 +8,6 @@ pub fn t<T: std::fmt::Display + std::fmt::Debug>(
lang: &Lang, lang: &Lang,
t: &Arc<TranslationStorage>, t: &Arc<TranslationStorage>,
) -> Result<String> { ) -> Result<String> {
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())) Ok(t.to_lang(*lang, &s.to_string()))
} }

View File

@ -3,6 +3,53 @@ use std::sync::{Arc, RwLock};
pub mod filters; pub mod filters;
#[derive(Debug, Clone, Default)]
pub struct Errors {
errors: Vec<String>,
field_errors: Vec<(String, garde::Error)>,
}
impl Errors {
pub fn push_global<S: Into<String>>(&mut self, s: S) {
self.errors.push(s.into());
}
pub fn push_field<S: Into<String>>(&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<String> {
self.field_errors
.iter()
.filter_map(|(field, error)| {
if field.contains(name) {
Some(error.message.clone().into())
} else {
None
}
})
.collect()
}
}
impl From<garde::Errors> for Errors {
fn from(value: garde::Errors) -> Self {
Errors {
errors: vec![],
field_errors: value.flatten(),
}
}
}
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)] #[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
pub enum Lang { pub enum Lang {
Pl, Pl,

View File

@ -1,3 +1,12 @@
import './elements/oswilno-price.js'; import './elements/oswilno-price.js';
import './elements/oswilno-error.js'; import './elements/oswilno-error.js';
import("https://unpkg.com/htmx.org@1.9.4/dist/htmx.min.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;
}
});