Skip to content

Commit

Permalink
Implement password resetting API
Browse files Browse the repository at this point in the history
  • Loading branch information
GrantGryczan committed Dec 22, 2024
1 parent 4621b50 commit 0a91d08
Show file tree
Hide file tree
Showing 12 changed files with 324 additions and 6 deletions.

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.

4 changes: 2 additions & 2 deletions migrations/20240522023049_initialize.sql
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ CREATE UNIQUE INDEX unverified_user_emails ON unverified_emails (email)

CREATE TABLE password_resets (
created_at timestamptz NOT NULL DEFAULT now(),
user_id bytea PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
token_hash bytea NOT NULL
token_hash bytea PRIMARY KEY,
user_id bytea NOT NULL UNIQUE REFERENCES users (id) ON DELETE CASCADE
);

CREATE TABLE sessions (
Expand Down
2 changes: 2 additions & 0 deletions src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod v1 {
//! The routes for version 1 of the HTTP API.
pub mod email_verification;
pub mod password_reset;
pub mod users;
}

Expand All @@ -27,6 +28,7 @@ pub(super) static ROUTER: LazyLock<Router> = LazyLock::new(|| {
"/api/v1/email-verification/code",
post(v1::email_verification::code::post),
)
.route("/api/v1/password-reset", post(v1::password_reset::post))
.route("/api/v1/users", post(v1::users::post))
.fallback(|| async { api::Error::RouteNotFound })
});
171 changes: 171 additions & 0 deletions src/api/routes/v1/password_reset.rs
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 {}
31 changes: 31 additions & 0 deletions src/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use lettre::{
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};

use crate::WEBSITE_ORIGIN;

/// An email template asking a user to verify their email.
#[derive(Template, Debug)]
#[template(path = "email/verification.html")]
Expand Down Expand Up @@ -42,6 +44,35 @@ impl MessageTemplate for EmailTakenMessage<'_> {
}
}

/// An email template giving a user a link to reset their password.
#[derive(Template, Debug)]
#[template(path = "email/password_reset.html")]
pub(crate) struct PasswordResetMessage<'a> {
/// The URL the user must visit to reset their password.
pub(crate) password_reset_url: &'a str,
}

impl MessageTemplate for PasswordResetMessage<'_> {
fn subject(&self) -> String {
"Reset your password? - File Garden".into()
}
}

/// An email template informing a user that someone tried to reset a password for their email
/// despite them not having an account.
#[derive(Template, Debug)]
#[template(path = "email/password_reset_failed.html")]
pub(crate) struct PasswordResetFailedMessage<'a> {
/// The email address that the password reset was submitted with.
pub(crate) email: &'a str,
}

impl MessageTemplate for PasswordResetFailedMessage<'_> {
fn subject(&self) -> String {
"Password reset failed - File Garden".into()
}
}

/// The mailbox automated emails are sent from.
static FROM_MAILBOX: LazyLock<Mailbox> = LazyLock::new(|| {
dotenvy::var("FROM_MAILBOX")
Expand Down
15 changes: 15 additions & 0 deletions templates/email/password_reset.html
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>
15 changes: 15 additions & 0 deletions templates/email/password_reset_failed.html
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>

0 comments on commit 0a91d08

Please sign in to comment.