Register and validations

This commit is contained in:
eraden 2023-08-05 22:20:23 +02:00
parent 1317863cec
commit daf472fd3e
8 changed files with 306 additions and 43 deletions

117
Cargo.lock generated
View File

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

View File

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

View File

@ -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<String>,
login: String,
#[serde(skip)]
login_errors: Vec<String>,
#[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<String>,
#[garde(length(min = 8, max = 50), custom(is_strong_password))]
password: String,
#[serde(skip)]
password_errors: Vec<String>,
}
#[derive(askama_actix::Template)]
@ -296,6 +300,7 @@ struct RegisterTemplate {
form: AccountInfo,
t: Arc<TranslationStorage>,
lang: Lang,
errors: oswilno_view::Errors,
}
#[get("/register")]
@ -304,6 +309,7 @@ async fn register_view(t: Data<TranslationStorage>) -> 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<TranslationStorage>) -> 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<TranslationStorage>,
lang: Lang,
errors: oswilno_view::Errors,
}
#[autometrics]
@ -331,14 +339,16 @@ async fn register(
t: Data<oswilno_view::TranslationStorage>,
) -> Result<HttpResponse, Error> {
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<DatabaseConnection>,
mut p: AccountInfo,
t: Arc<TranslationStorage>,
p: AccountInfo,
errors: &mut oswilno_view::Errors,
) -> Result<HttpResponse, AccountInfo> {
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);
}
};

View File

@ -1,26 +1,49 @@
<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">
{% for error in form.errors %}
{% for error in errors.global() %}
<oswilno-error>{{error|t(lang,t)}}</oswilno-error>
{% endfor %}
<form hx-post="/register" hx-target="#main-view">
<div class="mb-4">
<label for="login" class="block mb-2 text-sm text-gray-600">Login</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"/>
{% for error in form.login_errors %}
<oswilno-error>{{error|t(lang,t)}}</oswilno-error>
<label for="login" class="block mb-2 text-sm text-gray-600">{{"Login"|t(lang,t)}}</label>
<input
id="login"
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 %}
</div>
<div class="mb-4">
<label for="email" class="block mb-2 text-sm text-gray-600">E-Mail</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"/>
{% for error in form.email_errors %}
<oswilno-error>{{error|t(lang,t)}}</oswilno-error>
<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"
/>
{% for error in errors.field("email") %}
<oswilno-error class="mb-2 mt-2">{{error|t(lang,t)}}</oswilno-error>
{% endfor %}
</div>
<div class="mb-4">
<label for="password" class="block mb-2 text-sm text-gray-600">Password</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"/>
<label for="password" class="block mb-2 text-sm text-gray-600">{{"Password"|t(lang,t)}}</label>
<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 class="mb-6">
<input

View File

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

View File

@ -8,6 +8,6 @@ pub fn t<T: std::fmt::Display + std::fmt::Debug>(
lang: &Lang,
t: &Arc<TranslationStorage>,
) -> 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()))
}

View File

@ -3,6 +3,53 @@ use std::sync::{Arc, RwLock};
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)]
pub enum Lang {
Pl,

View File

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