-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
- Loading branch information
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
//! The set of email verification requests for new users. | ||
use axum::http::StatusCode; | ||
use axum_macros::debug_handler; | ||
use lettre::message::Mailbox; | ||
use serde::{Deserialize, Serialize}; | ||
use sqlx::Acquire; | ||
|
||
use crate::{ | ||
api::{ | ||
self, captcha, | ||
validation::{CaptchaToken, UserEmail}, | ||
Json, Query, Response, | ||
}, | ||
crypto::hash_without_salt, | ||
db::{self, TxResult}, | ||
email::{MessageTemplate, PasswordResetFailedMessage, PasswordResetMessage, SendMessage}, | ||
id::{Id, Token}, | ||
WEBSITE_ORIGIN, | ||
}; | ||
|
||
/// A `GET` request query for this API route. | ||
#[derive(Deserialize, Debug)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct GetQuery { | ||
/// The password reset token. | ||
token: Token, | ||
} | ||
|
||
/// Checks an existing password reset request. | ||
/// | ||
/// # Errors | ||
/// | ||
/// See [`crate::api::Error`]. | ||
#[debug_handler] | ||
pub async fn get(Query(query): Query<GetQuery>) -> Response<GetResponse> { | ||
let token_hash = hash_without_salt(&query.token); | ||
|
||
let Some(password_reset) = db::transaction!(async |tx| -> TxResult<_, api::Error> { | ||
Ok(sqlx::query!( | ||
"SELECT user_id FROM password_resets | ||
WHERE token_hash = $1", | ||
token_hash.as_ref(), | ||
) | ||
.fetch_optional(tx.as_mut()) | ||
.await?) | ||
}) | ||
.await? | ||
else { | ||
return Err(api::Error::ResourceNotFound); | ||
}; | ||
|
||
let user_id = Id::from(password_reset.user_id); | ||
|
||
Ok(( | ||
StatusCode::OK, | ||
Json(GetResponse { | ||
user_id: user_id.to_string(), | ||
}), | ||
)) | ||
} | ||
|
||
/// A `GET` response body for this API route. | ||
#[derive(Serialize, Debug)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct GetResponse { | ||
/// The ID of the user whose password reset was requested. | ||
pub user_id: String, | ||
} | ||
|
||
/// A `POST` request body for this API route. | ||
#[derive(Deserialize, Debug)] | ||
#[serde(rename_all = "camelCase", deny_unknown_fields)] | ||
pub struct PostRequest { | ||
/// The email address of the user to request a password reset for. | ||
pub email: UserEmail, | ||
|
||
/// A token to verify this request was submitted manually. | ||
pub captcha_token: CaptchaToken, | ||
} | ||
|
||
/// Sends a password reset request to the specified email. If there is no user associated with the | ||
/// email, a failure notification email is sent instead. | ||
/// | ||
/// # Errors | ||
/// | ||
/// See [`crate::api::Error`]. | ||
#[debug_handler] | ||
pub async fn post(Json(body): Json<PostRequest>) -> Response<PostResponse> { | ||
// We don't want bots spamming people with password reset emails. | ||
if !captcha::verify(&body.captcha_token).await? { | ||
return Err(api::Error::CaptchaFailed); | ||
} | ||
|
||
db::transaction!(async |tx| -> TxResult<_, api::Error> { | ||
let user = sqlx::query!( | ||
"SELECT id, name FROM users | ||
WHERE email = $1", | ||
body.email.as_str(), | ||
) | ||
.fetch_optional(tx.as_mut()) | ||
.await?; | ||
|
||
let Some(user) = user else { | ||
PasswordResetFailedMessage { | ||
email: body.email.as_str(), | ||
} | ||
.to(Mailbox::new(None, (*body.email).clone())) | ||
.send(); | ||
|
||
return Ok(()); | ||
}; | ||
|
||
sqlx::query!( | ||
"DELETE FROM password_resets | ||
WHERE user_id = $1", | ||
user.id, | ||
) | ||
.execute(tx.as_mut()) | ||
.await?; | ||
|
||
let mut token = Token::generate()?; | ||
|
||
loop { | ||
// If this loop's query fails from a token conflict, this savepoint is rolled back to | ||
// rather than aborting the entire transaction. | ||
let mut savepoint = tx.begin().await?; | ||
|
||
let token_hash = hash_without_salt(&token); | ||
|
||
match sqlx::query!( | ||
"INSERT INTO password_resets (token_hash, user_id) | ||
VALUES ($1, $2)", | ||
token_hash.as_ref(), | ||
user.id, | ||
) | ||
.execute(savepoint.as_mut()) | ||
.await | ||
{ | ||
Err(sqlx::Error::Database(error)) | ||
if error.constraint() == Some("password_resets_pkey") => | ||
{ | ||
token.reroll()?; | ||
continue; | ||
} | ||
result => result?, | ||
}; | ||
|
||
savepoint.commit().await?; | ||
break; | ||
} | ||
|
||
PasswordResetMessage { | ||
password_reset_url: &format!("{}/password-reset?token={}", *WEBSITE_ORIGIN, token), | ||
} | ||
.to(Mailbox::new(Some(user.name), (*body.email).clone())) | ||
.send(); | ||
|
||
Ok(()) | ||
}) | ||
.await?; | ||
|
||
// To prevent user enumeration, send this same successful response even if the user doesn't | ||
// exist. | ||
Ok((StatusCode::OK, Json(PostResponse {}))) | ||
} | ||
|
||
/// A `POST` response body for this API route. | ||
#[derive(Serialize, Debug)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct PostResponse {} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
<p> | ||
Hi there, | ||
</p> | ||
<p> | ||
To reset your File Garden account's password, visit the following link: | ||
</p> | ||
<p> | ||
<a href="{{ password_reset_url }}">{{ password_reset_url }}</a> | ||
</p> | ||
<p> | ||
If you didn't request this email, you can safely ignore it. | ||
</p> | ||
<p> | ||
Thanks for using File Garden. :) | ||
</p> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
<p> | ||
Hi there, | ||
</p> | ||
<p> | ||
If you tried to reset your File Garden password with the email <a style="font-weight: bold;">{{ email }}</a>, the password reset request couldn't be fulfilled because there is no verified File Garden account associated with that email. | ||
</p> | ||
<p> | ||
<ul style="padding-left: 1em;"> | ||
<li>If this was you, try <a href="{{ WEBSITE_ORIGIN.as_str() }}/sign-in">signing in</a> with a different email, or <a href="{{ WEBSITE_ORIGIN.as_str() }}/sign-up">create a new account</a> instead.</li> | ||
<li>If this wasn't you, you can safely ignore this email.</li> | ||
</ul> | ||
</p> | ||
<p> | ||
Thank you for your interest in File Garden. :) | ||
</p> |