Do not update user email if there is an error when sending the validation link to the new email

This commit is contained in:
Greg Burri 2025-05-07 01:12:51 +02:00
parent 45f4e2d169
commit 3ab168fd67
10 changed files with 103 additions and 68 deletions

12
Cargo.lock generated
View file

@ -2586,9 +2586,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.26" version = "0.23.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321"
dependencies = [ dependencies = [
"log", "log",
"once_cell", "once_cell",
@ -2607,9 +2607,9 @@ checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.1" version = "0.103.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" checksum = "7149975849f1abb3832b246010ef62ccc80d3a76169517ada7188252b9cfb437"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@ -3368,9 +3368,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.44.2" version = "1.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",

View file

@ -5,15 +5,19 @@ $dark-theme: false !default;
$color-1: #B29B89; $color-1: #B29B89;
$color-2: #89B29B; $color-2: #89B29B;
$color-3: #9B89B2; $color-3: #9B89B2;
$color-highlight: #cf2d2dff;
$text-color: color.adjust($color-1, $lightness: -30%); $text-color: color.adjust($color-1, $lightness: -30%);
$text-highlight: color.adjust($color-1, $lightness: +30%); $text-highlight: color.adjust($color-1, $lightness: +30%);
$link-color: color.adjust($color-3, $lightness: -25%); $link-color: color.adjust($color-3, $lightness: -25%);
$link-hover-color: color.adjust($color-3, $lightness: +20%); $link-hover-color: color.adjust($color-3, $lightness: +20%);
@if $dark-theme { @if $dark-theme {
$text-color: color.adjust($color-1, $lightness: -10%); $text-color: color.adjust($color-1, $lightness: -10%);
$text-highlight: color.adjust($color-1, $lightness: +10%); $text-highlight: color.adjust($color-1, $lightness: +10%);
$color-highlight: color.adjust($color-highlight, $lightness: +10%);
$link-color: color.adjust($color-3, $lightness: -5%); $link-color: color.adjust($color-3, $lightness: -5%);
$link-hover-color: color.adjust($color-3, $lightness: +10%); $link-hover-color: color.adjust($color-3, $lightness: +10%);

View file

@ -28,6 +28,7 @@ body {
.user-message { .user-message {
font-weight: bold; font-weight: bold;
color: consts.$color-highlight;
} }
.drag-handle { .drag-handle {
@ -286,7 +287,7 @@ body {
} }
} }
#user-edit form { #reset-password form {
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
input[type="submit"] { input[type="submit"] {
@ -294,48 +295,13 @@ body {
} }
} }
// #sign-in { #user-edit form {
grid-template-columns: auto 1fr auto;
// } input[type="submit"] {
grid-column: 2
// #user-edit { }
// .label-name { }
// grid-column: 1;
// grid-row: 1;
// }
// .input-name {
// grid-column: 2;
// grid-row: 1;
// }
// .label-password-1 {
// grid-column: 1;
// grid-row: 2;
// }
// .input-password-1 {
// grid-column: 2;
// grid-row: 2;
// }
// .label-password-2 {
// grid-column: 1;
// grid-row: 3;
// }
// .input-password-2 {
// grid-column: 2;
// grid-row: 3;
// }
// .button-save {
// grid-column: 2;
// grid-row: 4;
// width: fit-content;
// justify-self: flex-end;
// }
//}
} }
} }

View file

@ -8,6 +8,7 @@ use crate::{
consts, consts,
data::model, data::model,
hash::{hash, verify_password}, hash::{hash, verify_password},
services::user,
}; };
#[derive(Debug, Display)] #[derive(Debug, Display)]
@ -20,8 +21,8 @@ pub enum SignUpResult {
#[derive(Debug, Display)] #[derive(Debug, Display)]
pub enum UpdateUserResult { pub enum UpdateUserResult {
EmailAlreadyTaken, EmailAlreadyTaken,
/// Validation token. /// (New validation token, old email, old validation token, old validation time).
UserUpdatedWaitingForRevalidation(String), UserUpdatedWaitingForRevalidation(String, String, Option<String>, String),
Ok, Ok,
} }
@ -105,11 +106,22 @@ FROM [UserLoginToken] WHERE [token] = $1
let mut tx = self.tx().await?; let mut tx = self.tx().await?;
let hashed_new_password = new_password.map(|p| hash(p).unwrap()); let hashed_new_password = new_password.map(|p| hash(p).unwrap());
let (email, name, default_servings, first_day_of_the_week, hashed_password) = sqlx::query_as::< let (
_, email,
(String, String, u32, u8, String), name,
>( default_servings,
"SELECT [email], [name], [default_servings], [first_day_of_the_week], [password] FROM [User] WHERE [id] = $1", first_day_of_the_week,
hashed_password,
validation_token,
validation_token_datetime,
) = sqlx::query_as::<_, (String, String, u32, u8, String, Option<String>, String)>(
r#"
SELECT
[email], [name], [default_servings],
[first_day_of_the_week], [password],
[validation_token], [validation_token_datetime]
FROM [User] WHERE [id] = $1
"#,
) )
.bind(user_id) .bind(user_id)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
@ -119,7 +131,7 @@ FROM [UserLoginToken] WHERE [token] = $1
let email_changed = new_email.is_some_and(|new_email| new_email != email); let email_changed = new_email.is_some_and(|new_email| new_email != email);
// Check if email not already taken. // Check if email not already taken.
let validation_token = if email_changed { let new_validation_token = if email_changed {
if sqlx::query_scalar( if sqlx::query_scalar(
r#" r#"
SELECT COUNT(*) > 0 SELECT COUNT(*) > 0
@ -170,13 +182,41 @@ WHERE [id] = $1
tx.commit().await?; tx.commit().await?;
Ok(if let Some(validation_token) = validation_token { Ok(if let Some(new_validation_token) = new_validation_token {
UpdateUserResult::UserUpdatedWaitingForRevalidation(validation_token) UpdateUserResult::UserUpdatedWaitingForRevalidation(
new_validation_token,
email,
validation_token,
validation_token_datetime,
)
} else { } else {
UpdateUserResult::Ok UpdateUserResult::Ok
}) })
} }
pub async fn update_user_email_and_token(
&self,
user_id: i64,
email: &str,
validation_token: Option<&str>,
validation_token_datetime: &str,
) -> Result<()> {
sqlx::query(
r#"
UPDATE [User]
SET [email] = $2, [validation_token] = $3, [validation_token_datetime] = $4
WHERE [id] = $1"#,
)
.bind(user_id)
.bind(email)
.bind(validation_token)
.bind(validation_token_datetime)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_user_lang(&self, user_id: i64, lang: &str) -> Result<()> { pub async fn set_user_lang(&self, user_id: i64, lang: &str) -> Result<()> {
sqlx::query("UPDATE [User] SET [lang] = $2 WHERE [id] = $1") sqlx::query("UPDATE [User] SET [lang] = $2 WHERE [id] = $1")
.bind(user_id) .bind(user_id)
@ -975,7 +1015,7 @@ VALUES
assert_eq!(user.name, "paul"); assert_eq!(user.name, "paul");
assert_eq!(user.email, "paul@atreides.com"); assert_eq!(user.email, "paul@atreides.com");
if let UpdateUserResult::UserUpdatedWaitingForRevalidation(token) = connection if let UpdateUserResult::UserUpdatedWaitingForRevalidation(token, _, _, _) = connection
.update_user( .update_user(
1, 1,
Some("muaddib@fremen.com"), Some("muaddib@fremen.com"),

View file

@ -474,9 +474,6 @@ pub async fn ask_reset_password_post(
email, email,
message_email: match error { message_email: match error {
AskResetPasswordError::InvalidEmail => context.tr.t(Sentence::InvalidEmail), AskResetPasswordError::InvalidEmail => context.tr.t(Sentence::InvalidEmail),
_ => "",
},
message: match error {
AskResetPasswordError::EmailAlreadyReset => { AskResetPasswordError::EmailAlreadyReset => {
context.tr.t(Sentence::AskResetEmailAlreadyResetError) context.tr.t(Sentence::AskResetEmailAlreadyResetError)
} }
@ -484,6 +481,9 @@ pub async fn ask_reset_password_post(
AskResetPasswordError::UnableSendEmail(_) => { AskResetPasswordError::UnableSendEmail(_) => {
context.tr.t(Sentence::UnableToSendResetEmail) context.tr.t(Sentence::UnableToSendResetEmail)
} }
_ => "",
},
message: match error {
AskResetPasswordError::DatabaseError(_) => { AskResetPasswordError::DatabaseError(_) => {
context.tr.t(Sentence::DatabaseError) context.tr.t(Sentence::DatabaseError)
} }
@ -853,7 +853,12 @@ pub async fn edit_user_post(
Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => { Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, context); return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, context);
} }
Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => { Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(
token,
old_email,
old_token,
old_token_datetime,
)) => {
let url = utils::get_url_from_host(&host); let url = utils::get_url_from_host(&host);
let email = form_data.email.clone(); let email = form_data.email.clone();
match email_service match email_service
@ -874,6 +879,22 @@ pub async fn edit_user_post(
message = context.tr.t(Sentence::ProfileEmailSent); message = context.tr.t(Sentence::ProfileEmailSent);
} }
Err(error) => { Err(error) => {
// If the email can't be set we revert the changes about email and token.
if let Err(error) = connection
.update_user_email_and_token(
user.id,
&old_email,
old_token.as_deref(),
&old_token_datetime,
)
.await
{
error!(
"Unable to set email and token: (email={}): {}",
email, error
);
}
return error_response( return error_response(
ProfileUpdateError::UnableToSendEmail(error), ProfileUpdateError::UnableToSendEmail(error),
&form_data, &form_data,

View file

@ -8,7 +8,7 @@
<input id="email_field" type="email" <input id="email_field" type="email"
name="email" value="{{ email }}" name="email" value="{{ email }}"
autocapitalize="none" autocomplete="email" autofocus="autofocus"> autocapitalize="none" autocomplete="email" autofocus="autofocus">
<span class="user-message">{{ message_email }}</span> <span class="user-message ">{{ message_email }}</span>
<input type="submit" name="commit" value="{{ context.tr.t(Sentence::AskResetButton) }}"> <input type="submit" name="commit" value="{{ context.tr.t(Sentence::AskResetButton) }}">
</form> </form>

View file

@ -6,17 +6,18 @@
<form action="/reset_password" method="post"> <form action="/reset_password" method="post">
<label for="password_field_1">{{ context.tr.tp(Sentence::AskResetChooseNewPassword, [Box::new(common::consts::MIN_PASSWORD_SIZE)]) }}</label> <label for="password_field_1">{{ context.tr.tp(Sentence::AskResetChooseNewPassword, [Box::new(common::consts::MIN_PASSWORD_SIZE)]) }}</label>
<input id="password_field_1" type="password" name="password_1"> <input id="password_field_1" type="password" name="password_1">
<span></span>
<label for="password_field_1">{{ context.tr.t(Sentence::ReEnterPassword) }}</label> <label for="password_field_1">{{ context.tr.t(Sentence::ReEnterPassword) }}</label>
<input id="password_field_2" type="password" name="password_2"> <input id="password_field_2" type="password" name="password_2">
<span class="user-message">{{ message_password }}</span>
{{ message_password }}
<input type="hidden" name="reset_token" value="{{ reset_token }}"> <input type="hidden" name="reset_token" value="{{ reset_token }}">
<input type="submit" name="commit" value="Reset password"> <input type="submit" name="commit" value="{{ context.tr.t(Sentence::AskResetSubmit) }}">
</form> </form>
{{ message }}
<span class="user-message">{{ message }}</span>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -63,6 +63,7 @@
(AskResetEmailSent, "An email has been sent, follow the link to reset your password"), (AskResetEmailSent, "An email has been sent, follow the link to reset your password"),
(AskResetTokenMissing, "Reset token missing"), (AskResetTokenMissing, "Reset token missing"),
(AskResetTokenExpired, "Token expired, try to reset password again"), (AskResetTokenExpired, "Token expired, try to reset password again"),
(AskResetSubmit, "Reset password"),
(PasswordReset, "Your password has been reset"), (PasswordReset, "Your password has been reset"),
(EmailUnknown, "Email unknown"), (EmailUnknown, "Email unknown"),
(UnableToSendResetEmail, "Unable to send the reset password email"), (UnableToSendResetEmail, "Unable to send the reset password email"),

View file

@ -63,6 +63,7 @@
(AskResetEmailSent, "Un email a été envoyé, suivez le lien pour réinitialiser votre mot de passe"), (AskResetEmailSent, "Un email a été envoyé, suivez le lien pour réinitialiser votre mot de passe"),
(AskResetTokenMissing, "Jeton de réinitialisation manquant"), (AskResetTokenMissing, "Jeton de réinitialisation manquant"),
(AskResetTokenExpired, "Jeton expiré, essayez de réinitialiser votre mot de passe à nouveau"), (AskResetTokenExpired, "Jeton expiré, essayez de réinitialiser votre mot de passe à nouveau"),
(AskResetSubmit, "Réinitialiser le mot de passe"),
(PasswordReset, "Votre mot de passe a été réinitialisé"), (PasswordReset, "Votre mot de passe a été réinitialisé"),
(EmailUnknown, "Email inconnu"), (EmailUnknown, "Email inconnu"),
(UnableToSendResetEmail, "Impossible d'envoyer l'email pour la réinitialisation du mot de passe"), (UnableToSendResetEmail, "Impossible d'envoyer l'email pour la réinitialisation du mot de passe"),

View file

@ -68,6 +68,7 @@ pub enum Sentence {
AskResetEmailSent, AskResetEmailSent,
AskResetTokenMissing, AskResetTokenMissing,
AskResetTokenExpired, AskResetTokenExpired,
AskResetSubmit,
PasswordReset, PasswordReset,
EmailUnknown, EmailUnknown,
UnableToSendResetEmail, UnableToSendResetEmail,