feat: enforce implicit lowercase email address

This commit is contained in:
Max Hohlfeld 2025-06-15 21:13:05 +02:00
parent 90ac5c306d
commit cca925f4eb
8 changed files with 240 additions and 133 deletions

View File

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM user_ WHERE email = $1;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "b4edfcac9404060d487db765b8c18ef8b7440699583e0bede95f4d214e668a87"
}

View File

@ -28,7 +28,8 @@ pub async fn post_edit(
};
let role = form.role.try_into()?;
if user.role == Role::AreaManager && (user.area_id != user_in_db.area_id || role == Role::Admin) {
if user.role == Role::AreaManager && (user.area_id != user_in_db.area_id || role == Role::Admin)
{
return Err(ApplicationError::Unauthorized);
}
@ -53,14 +54,21 @@ pub async fn post_edit(
let changeset = UserChangeset {
name: form.name.clone(),
email: form.email.clone(),
email: form.email.to_lowercase(),
role,
functions,
area_id,
};
if let Some(existing_id) = User::exists(pool.get_ref(), &changeset.email).await? {
if existing_id != user_in_db.id {
return Ok(HttpResponse::UnprocessableEntity()
.body("email: an user already exists with the same email"));
}
}
if let Err(e) = changeset.validate() {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
};
User::update(pool.get_ref(), user_in_db.id, changeset).await?;
@ -145,4 +153,67 @@ mod tests {
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::BAD_REQUEST, response.status());
}
#[db_test]
async fn email_gets_cast_to_lowercase(context: &DbTestContext) {
User::create(&context.db_pool, Faker.fake()).await.unwrap();
Area::create(&context.db_pool, "Süd").await.unwrap();
let app = context.app().await;
let config = RequestConfig::new("/users/edit/1").with_role(Role::Admin);
let new_name: String = Name().fake();
let new_mail: String = String::from("NONLowercaseEMAIL@example.com");
let form = NewOrEditUserForm {
name: new_name.clone(),
email: new_mail.clone(),
role: Role::AreaManager as u8,
is_posten: None,
is_wachhabender: None,
is_fuehrungsassistent: Some(true),
area: Some(2),
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::FOUND, response.status());
let updated_user = User::read_by_id(&context.db_pool, 1)
.await
.unwrap()
.unwrap();
assert_eq!(new_mail.to_lowercase(), updated_user.email);
}
#[db_test]
async fn fails_when_email_already_present(context: &DbTestContext) {
User::create(&context.db_pool, Faker.fake()).await.unwrap();
User::create(&context.db_pool, Faker.fake()).await.unwrap();
Area::create(&context.db_pool, "Süd").await.unwrap();
let app = context.app().await;
let config = RequestConfig::new("/users/edit/1").with_role(Role::Admin);
let second_user = User::read_by_id(&context.db_pool, 2)
.await
.unwrap()
.unwrap();
let new_name: String = Name().fake();
let new_mail: String = second_user.email;
let form = NewOrEditUserForm {
name: new_name.clone(),
email: new_mail.clone(),
role: Role::AreaManager as u8,
is_posten: None,
is_wachhabender: None,
is_fuehrungsassistent: Some(true),
area: Some(2),
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::BAD_REQUEST, response.status());
}
}

View File

@ -18,7 +18,7 @@ async fn post(
request: HttpRequest,
pool: web::Data<PgPool>,
) -> impl Responder {
if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email).await {
if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email.to_lowercase()).await {
let salt = user.salt.unwrap();
let hash = hash_plain_password_with_salt(&form.password, &salt).unwrap();

View File

@ -47,14 +47,19 @@ pub async fn post_new(
let changeset = UserChangeset {
name: form.name.clone(),
email: form.email.clone(),
email: form.email.to_lowercase(),
role,
functions,
area_id,
};
if let Some(_) = User::exists(pool.get_ref(), &changeset.email).await? {
return Ok(HttpResponse::UnprocessableEntity()
.body("email: an user already exists with the same email"));
}
if let Err(e) = changeset.validate() {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
};
let id = User::create(pool.get_ref(), changeset).await?;

View File

@ -29,7 +29,9 @@ async fn post(
&& form.password.is_none()
&& form.passwordretyped.is_none()
{
if let Ok(user) = User::read_for_login(pool.get_ref(), form.email.as_ref().unwrap()).await {
if let Ok(user) =
User::read_for_login(pool.get_ref(), &form.email.as_ref().unwrap().to_lowercase()).await
{
let reset = PasswordReset::insert_new_for_user(pool.get_ref(), user.id).await?;
mailer
.send_forgot_password_mail(&user, &reset.token)

View File

@ -147,6 +147,14 @@ impl User {
Ok(result)
}
pub async fn exists(pool: &PgPool, email: &str) -> Result<Option<i32>> {
let record = sqlx::query!("SELECT id FROM user_ WHERE email = $1;", email)
.fetch_optional(pool)
.await?;
Ok(record.and_then(|r| Some(r.id)))
}
pub async fn read_all(pool: &PgPool) -> anyhow::Result<Vec<User>> {
let records = sqlx::query!(
r#"

View File

@ -21,6 +21,7 @@ pub struct RequestConfig {
}
impl RequestConfig {
/// Creates a new [`RequestConfig`] with User as [`Role::Staff`] and [`Function::Posten`] in Area 1.
pub fn new(uri: &str) -> Self {
Self {
uri: uri.to_string(),

View File

@ -3,13 +3,10 @@
{% block content %}
<section class="section">
<div class="container">
{% if id.is_some() %}
<form method="post" action="/users/edit/{{ id.unwrap() }}">
<h1 class="title">Nutzer '{{ name.as_ref().unwrap() }}' bearbeiten</h1>
{% else %}
<form method="post" action="/users/new">
<h1 class="title">Neuen Nutzer anlegen</h1>
{% endif %}
<form hx-post="/users/{% if let Some(id) = id %}edit/{{ id }}{% else %}new{% endif %}" hx-target-422="find p">
<h1 class="title">
{% if let Some(name) = name %}Nutzer '{{ name }}' bearbeiten{% else %}Neuen Nutzer anlegen{% endif %}
</h1>
<div class="field is-horizontal">
<div class="field-label">
@ -19,8 +16,9 @@
<div class="field">
<div class="control">
<input class="input" type="text" name="email" placeholder="max.mustermann@brasiwa-leipzig.de" {{
email|insert_value|safe }} required />
email|insert_value|safe }} required _="on input put '' into the next <p/>" />
</div>
<p class="help is-danger"></p>
</div>
</div>
</div>