In this guide, you will learn how to add a forgot/reset password feature to a Rust API. The API is built on top of the high-performance Axum framework and utilizes the SQLx crate for seamless integration with a database.

To achieve this functionality, we will leverage additional crates such as Handlerbars, which enables dynamic generation of HTML email templates, and Lettre, which facilitates the sending of emails via the SMTP protocol.

In today’s digital landscape, where the risk of account hijacking and the sale of compromised credentials on the dark web are prevalent, it is crucial to use unique passwords for each online account. However, managing multiple passwords without the assistance of a password manager can increase the likelihood of forgetting them.

As developers, it is essential to address this challenge by implementing a robust forgot/password feature that allows users to reset their passwords in the event of a security breach or compromised account. With this in mind, let’s dive right in!

More practice:

Rust API - Forgot-Reset Password with Emails

Running the Rust API Project Locally

To run the Rust API project on your local machine, follow these steps:

  1. Download or clone the project from its GitHub repository at https://github.com/wpcodevo/rust-user-signup-forgot-password-email and open it in your preferred IDE or code editor.
  2. Duplicate the .env.example file and rename the copy to .env. Open the .env file and add your SMTP credentials to the SMTP_HOST, SMTP_USER, and SMTP_PASS variables. If you don’t have these credentials or are unsure how to obtain them, please refer to the instructions provided here.
  3. Ensure that you have the SQLX command-line tool installed. If not, you can install it by running the command cargo install sqlx-cli -F sqlite.
  4. Generate the SQLite database by running the command sqlx database create.
  5. Apply the SQLX migrations to the SQLite database by executing sqlx migrate run.
  6. Start the API server by running cargo run. Once the server is up and running, you can import the Rust Signup SignIn Email.postman_collection.json file into Postman or the Thunder Client VS Code extension to test the API endpoints.

Running the Rust API with a Frontend App

If you want to go beyond testing the API in Postman and see how it integrates with a frontend application, you’re in luck. Follow the instructions below to use a frontend app built with React.js to interact with the API. For a more detailed guide on building the React app, refer to the article titled “Forgot/Reset Passwords with React Query and Axios“.

  1. Download or clone the React.js project from https://github.com/wpcodevo/react-query-axios-tailwindcss and open the source code in your preferred IDE.
  2. In the terminal, navigate to the root directory of the project and run the command yarn or yarn install to install the necessary dependencies.
  3. Start the React development server by running yarn dev. Once the server is up and running, visit the provided URL in your browser to access the application. From there, you can interact with the various features such as signing up, signing in, verifying your account, requesting a password reset code, resetting your password, and accessing your profile page.

Setting up the Rust Project

Let’s start by setting up the project. This article is a continuation of a previous tutorial where we built a Rust API with features like user registration, login, email verification, and logout. In this article, we will focus on adding the forgot/reset password functionality to our API.

If you have arrived here from a Google search or my GitHub page, follow these steps to ensure we are all on the same page:

  1. Clone the project from its GitHub repository:
   git clone https://github.com/wpcodevo/rust-user-signup-forgot-password-email
  1. Open the project in your preferred IDE or text editor.
  2. Switch to the appropriate Git branch:
   git checkout rust-user-signup-email-verification

Once you have completed these steps, your project will be set up and ready to integrate the forgot/reset password feature.

Generating the Password Reset HTML Email Template

In the previous tutorial, we already created the base HTML template and CSS styles, so we won’t repeat those steps here. Instead, we need to add a new template specifically for sending the password reset link to the user. This template will be based on the email verification template with a few modifications.

To create the password reset template, follow these steps:

  1. Navigate to the ‘templates‘ directory in your project.
  2. Create a new file named reset_password.hbs.
  3. Add the following code to the reset_password.hbs file:

templates/reset_password.hbs


{{#> base}}
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
    <tr>
        <td>&nbsp;</td>
        <td class="container">
            <div class="content">
                <!-- START CENTERED WHITE CONTAINER -->
                <table role="presentation" class="main">
                    <!-- START MAIN CONTENT AREA -->
                    <tr>
                        <td class="wrapper">
                            <table role="presentation" border="0" cellpadding="0" cellspacing="0">
                                <tr>
                                    <td>
                                        <p>Hi {{first_name}},</p>
                                        <p>
                                            Forgot password? Send a PATCH request to with your
                                            password and passwordConfirm to {{url}}
                                        </p>
                                        <table role="presentation" border="0" cellpadding="0" cellspacing="0"
                                            class="btn btn-primary">
                                            <tbody>
                                                <tr>
                                                    <td align="left">
                                                        <table role="presentation" border="0" cellpadding="0"
                                                            cellspacing="0">
                                                            <tbody>
                                                                <tr>
                                                                    <td>
                                                                        <a href="{{url}}" target="_blank">Reset
                                                                            password</a>
                                                                    </td>
                                                                </tr>
                                                            </tbody>
                                                        </table>
                                                    </td>
                                                </tr>
                                            </tbody>
                                        </table>
                                        <p>
                                            If you didn't forget your password, please ignore this
                                            email
                                        </p>
                                        <p>Good luck! Codevo CEO.</p>
                                    </td>
                                </tr>
                            </table>
                        </td>
                    </tr>

                    <!-- END MAIN CONTENT AREA -->
                </table>
                <!-- END CENTERED WHITE CONTAINER -->
            </div>
        </td>
        <td>&nbsp;</td>
    </tr>
</table>
{{/base}}

Sending the Emails via SMTP

In our initial project, which involved sending email verification links to users, we adopted a structured approach that utilized a struct with methods for rendering HTML templates and sending emails based on those templates. This approach provides great flexibility, allowing us to easily send various types of emails. By simply adding a new method and invoking a single method, all the necessary requirements are automatically taken care of.

To send the password reset link, all we need to do is add a new method called send_password_reset_token and invoke the self.send_email method. That’s it! No further actions are required. If you need to send another type of email, you can follow the same pattern by adding a single method and invoking self.send_email with the appropriate data, and the email will be sent accordingly.

To implement the send_password_reset_token method, navigate to the email.rs file located in the src directory and implement the method within the Email struct.

Here is an example of the send_password_reset_token method implementation:

src/email.rs


impl Email {
    // Existing code...

    pub async fn send_password_reset_token(
        &self,
        password_reset_token_expires_in: i64,
    ) -> Result<(), Box<dyn std::error::Error>> {
        self.send_email(
            "reset_password",
            format!(
                "Your password reset token (valid for only {} minutes)",
                password_reset_token_expires_in
            )
            .as_str(),
        )
        .await
    }
}

After adding this method, your email.rs file should look like this:

src/email.rs


use handlebars::Handlebars;
use lettre::{
    message::header::ContentType, transport::smtp::authentication::Credentials, AsyncSmtpTransport,
    AsyncTransport, Message, Tokio1Executor,
};

use crate::{config::Config, model::User};

pub struct Email {
    user: User,
    url: String,
    from: String,
    config: Config,
}

impl Email {
    pub fn new(user: User, url: String, config: Config) -> Self {
        let from = format!("Codevo <{}>", config.smtp_from.to_owned());

        Email {
            user,
            url,
            from,
            config,
        }
    }

    fn new_transport(
        &self,
    ) -> Result<AsyncSmtpTransport<Tokio1Executor>, lettre::transport::smtp::Error> {
        let creds = Credentials::new(
            self.config.smtp_user.to_owned(),
            self.config.smtp_pass.to_owned(),
        );

        let transport = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(
            &self.config.smtp_host.to_owned(),
        )?
        .port(self.config.smtp_port)
        .credentials(creds)
        .build();

        Ok(transport)
    }

    fn render_template(&self, template_name: &str) -> Result<String, handlebars::RenderError> {
        let mut handlebars = Handlebars::new();
        handlebars
            .register_template_file(template_name, &format!("./templates/{}.hbs", template_name))?;
        handlebars.register_template_file("styles", "./templates/partials/styles.hbs")?;
        handlebars.register_template_file("base", "./templates/layouts/base.hbs")?;

        let data = serde_json::json!({
            "first_name": &self.user.name.split_whitespace().next().unwrap(),
            "subject": &template_name,
            "url": &self.url
        });

        let content_template = handlebars.render(template_name, &data)?;

        Ok(content_template)
    }

    async fn send_email(
        &self,
        template_name: &str,
        subject: &str,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let html_template = self.render_template(template_name)?;
        let email = Message::builder()
            .to(
                format!("{} <{}>", self.user.name.as_str(), self.user.email.as_str())
                    .parse()
                    .unwrap(),
            )
            .reply_to(self.from.as_str().parse().unwrap())
            .from(self.from.as_str().parse().unwrap())
            .subject(subject)
            .header(ContentType::TEXT_HTML)
            .body(html_template)?;

        let transport = self.new_transport()?;

        transport.send(email).await?;
        Ok(())
    }

    pub async fn send_verification_code(&self) -> Result<(), Box<dyn std::error::Error>> {
        self.send_email("verification_code", "Your account verification code")
            .await
    }

    pub async fn send_password_reset_token(
        &self,
        password_reset_token_expires_in: i64,
    ) -> Result<(), Box<dyn std::error::Error>> {
        self.send_email(
            "reset_password",
            format!(
                "Your password reset token (valid for only {} minutes)",
                password_reset_token_expires_in
            )
            .as_str(),
        )
        .await
    }
}

Performing Database Migrations

Now, we need to modify the existing users table and add two important columns: password_reset_token and password_reset_at. The password_reset_token field will store the password reset token, allowing us to verify it during the password reset process. The password_reset_at field will keep track of the creation time of the password reset token, enabling us to set an expiration time.

To add these fields, we’ll generate new migration scripts. Open your terminal and execute the following command:


sqlx migrate add -r "added password reset fields"

After the command finishes execution, you’ll find two new reversible migration scripts generated in the ‘migrations‘ folder at the root of your project. Open the ‘up‘ migration script and add the following SQL code:

migrations/20230630095915_added_password_reset_fields.up.sql


-- Add up migration script here
ALTER TABLE users ADD COLUMN password_reset_token VARCHAR(50);
CREATE INDEX idx_password_reset_token ON users(password_reset_token);

ALTER TABLE users ADD COLUMN password_reset_at TIMESTAMP;
CREATE INDEX idx_password_reset_at ON users(password_reset_at);

To allow the changes made by the ‘up‘ script to be reverted, open the corresponding ‘down‘ script and add the following SQL statements:

migrations/20230630095915_added_password_reset_fields.down.sql


-- Add down migration script here
ALTER TABLE users DROP COLUMN password_reset_token;
DROP INDEX idx_password_reset_token;

ALTER TABLE users DROP COLUMN password_reset_at;
DROP INDEX idx_password_reset_at;

Next, ensure that the SQLite database URL is defined in a .env file, as the SQLx CLI relies on it during the creation of the SQLite database and migrations. Duplicate the .env.example file and rename the copy to .env. Additionally, make sure to add your SMTP credentials to the .env file. If you don’t have these credentials or are unsure how to obtain them, refer to the instructions provided here.

Now you can run the command sqlx database create to create the SQLite database. To apply the migrations, run sqlx migrate run.

Adding More Fields to the Database Model

After adding the new columns to the users table, we need to include them in the User struct, which SQLx will use to map the database query results. To do this, open the model.rs file located in the src directory and add the fields to the User struct:

src/model.rs


#[allow(non_snake_case)]
#[derive(Debug, Deserialize, sqlx::FromRow, Serialize, Clone)]
pub struct User {
    pub password_reset_token: Option<String>,
    pub password_reset_at: Option<NaiveDateTime>,
}

Once you have made the necessary modifications to the User struct, your src/model.rs file should now look like this:

src/model.rs


use chrono::prelude::*;
use serde::{Deserialize, Serialize};

#[allow(non_snake_case)]
#[derive(Debug, Deserialize, sqlx::FromRow, Serialize, Clone)]
pub struct User {
    pub id: String,
    pub name: String,
    pub email: String,
    pub password: String,
    pub photo: String,
    pub verified: bool,
    pub verification_code: Option<String>,
    pub password_reset_token: Option<String>,
    pub password_reset_at: Option<NaiveDateTime>,
    pub role: String,
    #[serde(rename = "createdAt")]
    pub created_at: Option<NaiveDateTime>,
    #[serde(rename = "updatedAt")]
    pub updated_at: Option<NaiveDateTime>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct TokenClaims {
    pub sub: String,
    pub iat: usize,
    pub exp: usize,
}

#[derive(Debug, Deserialize)]
pub struct RegisterUserSchema {
    pub name: String,
    pub email: String,
    pub password: String,
}

#[derive(Debug, Deserialize)]
pub struct LoginUserSchema {
    pub email: String,
    pub password: String,
}

#[derive(Debug, Deserialize)]
pub struct ForgotPasswordSchema {
    pub email: String,
}

#[derive(Debug, Deserialize)]
pub struct ResetPasswordSchema {
    pub password: String,
    #[serde(rename = "passwordConfirm")]
    pub password_confirm: String,
}

With these updates, your database model now includes the additional fields required for the password reset functionality.

Creating the API Endpoint Handlers

Now we’ll implement the route handlers responsible for the password reset functionality. Let’s take a closer look at each handler:

  1. forgot_password_handler – This handler is invoked when a POST request is made to the /api/auth/forgotpassword endpoint. It sends the password reset email to the user’s registered email address and stores a copy of the reset token in the database.
  2. reset_password_handler – This handler is invoked when a PATCH request is made to the /api/auth/resetpassword/:token endpoint. It updates the user’s password in the database based on the new password provided in the request body.

Creating the Forgot Password Handler

Let’s start with the first handler, forgot_password_handler, which is the initial step we need to take. Let’s review the requirements. When the handler is invoked, it performs the following steps:

  1. Checks the database to see if the provided email in the request body exists. If not, it returns a generic error message like “You will receive a password reset email if a user with that email exists“. This prevents hackers from attempting to check if users exist in our database by trying different email addresses.
  2. Checks if the found user has verified their account.
  3. Generates a password reset token.
  4. Sends the password reset token in an email template to the user’s email address.
  5. If the email is successfully sent, stores a copy in the database along with the creation time.
  6. Returns a JSON response with a success message if all operations are successful.

To implement this handler, copy and paste the provided code into the handler.rs file.

src/handler.rs


fn generate_random_string(length: usize) -> String {
    let rng = rand::thread_rng();
    let random_string: String = rng
        .sample_iter(&Alphanumeric)
        .take(length)
        .map(char::from)
        .collect();

    random_string
}

pub async fn forgot_password_handler(
    State(data): State<Arc<AppState>>,
    Json(body): Json<ForgotPasswordSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let err_message = "You will receive a password reset email if user with that email exist";
    let email_address = body.email.to_owned().to_ascii_lowercase();

    let user: User = sqlx::query_as("SELECT * FROM users WHERE email = $1")
        .bind(&email_address.clone())
        .fetch_optional(&data.db)
        .await
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "error",
                message: format!("Database error: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })?
        .ok_or_else(|| {
            let error_response = ErrorResponse {
                status: "fail",
                message: err_message.to_string(),
            };
            (StatusCode::OK, Json(error_response))
        })?;

    if !user.verified {
        let error_response = ErrorResponse {
            status: "fail",
            message: "Account not verified".to_string(),
        };
        return Err((StatusCode::FORBIDDEN, Json(error_response)));
    }

    let password_reset_token = generate_random_string(20);
    let password_token_expires_in = 10; // 10 minutes
    let password_reset_at =
        chrono::Utc::now() + chrono::Duration::minutes(password_token_expires_in);

    let password_reset_url = format!(
        "{}/resetpassword/{}",
        data.config.frontend_origin.to_owned(),
        password_reset_token
    );

    let email_instance = Email::new(user, password_reset_url, data.config.clone());
    if let Err(_) = email_instance
        .send_password_reset_token(password_token_expires_in)
        .await
    {
        let json_error = ErrorResponse {
            status: "fail",
            message: "Something bad happended while sending the password reset code".to_string(),
        };
        return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json_error)));
    }

    sqlx::query(
        "UPDATE users SET password_reset_token = $1, password_reset_at = $2 WHERE email = $3",
    )
    .bind(password_reset_token)
    .bind(&password_reset_at.clone())
    .bind(&email_address.clone())
    .execute(&data.db)
    .await
    .map_err(|e| {
        let json_error = ErrorResponse {
            status: "fail",
            message: format!("Error updating user: {}", e),
        };
        (StatusCode::INTERNAL_SERVER_ERROR, Json(json_error))
    })?;

    let response = serde_json::json!({
            "status": "success",
            "message": err_message
        }
    );

    Ok(Json(response))
}

Creating the Reset Password Handler

Let’s move on to creating the reset password handler. To implement this functionality, open the handler.rs file and add the following code:

src/handler.rs


pub async fn reset_password_handler(
    State(data): State<Arc<AppState>>,
    Path(password_reset_token): Path<String>,
    Json(body): Json<ResetPasswordSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    if body.password != body.password_confirm {
        let error_response = ErrorResponse {
            status: "fail",
            message: "Passwords do not match".to_string(),
        };
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
    }

    let user: User = sqlx::query_as(
        "SELECT * FROM users WHERE password_reset_token = $1 AND password_reset_at > $2",
    )
    .bind(password_reset_token)
    .bind(chrono::Utc::now())
    .fetch_optional(&data.db)
    .await
    .map_err(|e| {
        let error_response = ErrorResponse {
            status: "error",
            message: format!("Database error: {}", e),
        };
        (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
    })?
    .ok_or_else(|| {
        let error_response = ErrorResponse {
            status: "fail",
            message: "The password reset token is invalid or has expired".to_string(),
        };
        (StatusCode::FORBIDDEN, Json(error_response))
    })?;

    let salt = SaltString::generate(&mut OsRng);
    let hashed_password = Argon2::default()
        .hash_password(body.password.as_bytes(), &salt)
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "fail",
                message: format!("Error while hashing password: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })
        .map(|hash| hash.to_string())?;

    sqlx::query(
        "UPDATE users SET password = $1, password_reset_token = $2, password_reset_at = $3 WHERE email = $4",
    )
    .bind(hashed_password)
    .bind(Option::<String>::None)
    .bind(Option::<String>::None)
    .bind(&user.email.clone().to_ascii_lowercase())
    .execute(&data.db)
    .await
    .map_err(|e| {
        let json_error = ErrorResponse {
            status: "fail",
            message: format!("Error updating user: {}", e),
        };
        (StatusCode::INTERNAL_SERVER_ERROR, Json(json_error))
    })?;

    let cookie = Cookie::build(("token", ""))
        .path("/")
        .max_age(time::Duration::minutes(-1))
        .same_site(SameSite::Lax)
        .http_only(true);

    let mut response = Response::new(
        json!({"status": "success", "message": "Password data updated successfully"}).to_string(),
    );
    response
        .headers_mut()
        .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
    Ok(response)
}

In this code, the handler performs the following steps:

  1. It checks if the passwords provided in the request body match. If they don’t, it returns a bad request error with a message indicating that the passwords do not match.
  2. The handler queries the database to retrieve the user based on the provided password reset token. It also checks if the password_reset_at field is greater than the current time to ensure that the password reset token has not expired.
  3. If there are no errors, the handler generates a salt and hashes the new password using the Argon2 library. The hashed password is then stored in the database.
  4. An expired cookie is returned to the user’s API client or browser to delete the existing cookie, forcing the user to re-authenticate with their new password.

The Complete Code of the Route Handlers

src/handler.rs


use std::sync::Arc;

use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::{
    extract::{Path, State},
    http::{header, Response, StatusCode},
    response::IntoResponse,
    Extension, Json,
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use jsonwebtoken::{encode, EncodingKey, Header};
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng};
use serde_json::json;

use crate::{
    email::Email,
    model::{
        ForgotPasswordSchema, LoginUserSchema, RegisterUserSchema, ResetPasswordSchema,
        TokenClaims, User,
    },
    response::{ErrorResponse, FilteredUser},
    AppState,
};

pub async fn health_checker_handler() -> impl IntoResponse {
    const MESSAGE: &str =
        "Rust - User Registration and Email Verification using Axum, Postgres, and SQLX";

    let json_response = serde_json::json!({
        "status": "success",
        "message": MESSAGE
    });

    Json(json_response)
}

pub async fn register_user_handler(
    State(data): State<Arc<AppState>>,
    Json(body): Json<RegisterUserSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let user_exists: Option<bool> =
        sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
            .bind(body.email.to_owned().to_ascii_lowercase())
            .fetch_one(&data.db)
            .await
            .map_err(|e| {
                let error_response = ErrorResponse {
                    status: "fail",
                    message: format!("Database error: {}", e),
                };
                (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
            })?;

    if let Some(exists) = user_exists {
        if exists {
            let error_response = ErrorResponse {
                status: "fail",
                message: "User with that email already exists".to_string(),
            };
            return Err((StatusCode::CONFLICT, Json(error_response)));
        }
    }

    let salt = SaltString::generate(&mut OsRng);
    let hashed_password = Argon2::default()
        .hash_password(body.password.as_bytes(), &salt)
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "fail",
                message: format!("Error while hashing password: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })
        .map(|hash| hash.to_string())?;

    let verification_code = generate_random_string(20);
    let email = body.email.to_owned().to_ascii_lowercase();
    let id = uuid::Uuid::new_v4().to_string();
    let verification_url = format!(
        "{}/verifyemail/{}",
        data.config.frontend_origin.to_owned(),
        verification_code
    );

    let user: User = sqlx::query_as(
        "INSERT INTO users (id,name,email,password) VALUES ($1, $2, $3, $4) RETURNING *",
    )
    .bind(id.clone())
    .bind(body.name.to_owned())
    .bind(email.clone())
    .bind(hashed_password)
    .fetch_one(&data.db)
    .await
    .map_err(|e| {
        let error_response = ErrorResponse {
            status: "fail",
            message: format!("Database error: {}", e),
        };
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(error_response.into()),
        )
    })?;

    //  Create an Email instance
    let email_instance = Email::new(user, verification_url, data.config.clone());
    if let Err(_) = email_instance.send_verification_code().await {
        let json_error = ErrorResponse {
            status: "fail",
            message: "Something bad happended while sending the verification code".to_string(),
        };
        return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json_error)));
    }

    sqlx::query("UPDATE users SET verification_code = $1 WHERE id = $2")
        .bind(verification_code)
        .bind(id)
        .execute(&data.db)
        .await
        .map_err(|e| {
            let json_error = ErrorResponse {
                status: "fail",
                message: format!("Error updating user: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(json_error))
        })?;

    let user_response = serde_json::json!({"status": "success","message": format!("We sent an email with a verification code to {}", email)});

    Ok(Json(user_response))
}

pub async fn login_user_handler(
    State(data): State<Arc<AppState>>,
    Json(body): Json<LoginUserSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let email = body.email.to_ascii_lowercase();
    let user: User = sqlx::query_as("SELECT * FROM users WHERE email = $1")
        .bind(email)
        .fetch_optional(&data.db)
        .await
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "error",
                message: format!("Database error: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })?
        .ok_or_else(|| {
            let error_response = ErrorResponse {
                status: "fail",
                message: "Invalid email or password".to_string(),
            };
            (StatusCode::BAD_REQUEST, Json(error_response))
        })?;

    if !user.verified {
        let error_response = ErrorResponse {
            status: "fail",
            message: "Please verify your email before you can log in".to_string(),
        };
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
    }

    let is_valid = match PasswordHash::new(&user.password) {
        Ok(parsed_hash) => Argon2::default()
            .verify_password(body.password.as_bytes(), &parsed_hash)
            .map_or(false, |_| true),
        Err(_) => false,
    };

    if !is_valid {
        let error_response = ErrorResponse {
            status: "fail",
            message: "Invalid email or password".to_string(),
        };
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
    }

    let now = chrono::Utc::now();
    let iat = now.timestamp() as usize;
    let exp = (now + chrono::Duration::minutes(60)).timestamp() as usize;
    let claims: TokenClaims = TokenClaims {
        sub: user.id.to_string(),
        exp,
        iat,
    };

    let token = encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(data.config.jwt_secret.as_ref()),
    )
    .unwrap();

    let cookie = Cookie::build(("token", token.to_owned()))
        .path("/")
        .max_age(time::Duration::hours(1))
        .same_site(SameSite::Lax)
        .http_only(true);

    let mut response = Response::new(json!({"status": "success", "token": token}).to_string());
    response
        .headers_mut()
        .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
    Ok(response)
}

pub async fn verify_email_handler(
    State(data): State<Arc<AppState>>,
    Path(verification_code): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let user: User = sqlx::query_as("SELECT * FROM users WHERE verification_code = $1")
        .bind(&verification_code)
        .fetch_optional(&data.db)
        .await
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "error",
                message: format!("Database error: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })?
        .ok_or_else(|| {
            let error_response = ErrorResponse {
                status: "fail",
                message: "Invalid verification code or user doesn't exist".to_string(),
            };
            (StatusCode::UNAUTHORIZED, Json(error_response))
        })?;

    if user.verified {
        let error_response = ErrorResponse {
            status: "fail",
            message: "User already verified".to_string(),
        };
        return Err((StatusCode::CONFLICT, Json(error_response)));
    }

    sqlx::query(
        "UPDATE users SET verification_code = $1, verified = $2 WHERE verification_code = $3",
    )
    .bind(Option::<String>::None)
    .bind(true)
    .bind(&verification_code)
    .execute(&data.db)
    .await
    .map_err(|e| {
        let json_error = ErrorResponse {
            status: "fail",
            message: format!("Error updating user: {}", e),
        };
        (StatusCode::INTERNAL_SERVER_ERROR, Json(json_error))
    })?;

    let response = serde_json::json!({
            "status": "success",
            "message": "Email verified successfully"
        }
    );

    Ok(Json(response))
}

pub async fn forgot_password_handler(
    State(data): State<Arc<AppState>>,
    Json(body): Json<ForgotPasswordSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let err_message = "You will receive a password reset email if user with that email exist";
    let email_address = body.email.to_owned().to_ascii_lowercase();

    let user: User = sqlx::query_as("SELECT * FROM users WHERE email = $1")
        .bind(&email_address.clone())
        .fetch_optional(&data.db)
        .await
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "error",
                message: format!("Database error: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })?
        .ok_or_else(|| {
            let error_response = ErrorResponse {
                status: "fail",
                message: err_message.to_string(),
            };
            (StatusCode::OK, Json(error_response))
        })?;

    if !user.verified {
        let error_response = ErrorResponse {
            status: "fail",
            message: "Account not verified".to_string(),
        };
        return Err((StatusCode::FORBIDDEN, Json(error_response)));
    }

    let password_reset_token = generate_random_string(20);
    let password_token_expires_in = 10; // 10 minutes
    let password_reset_at =
        chrono::Utc::now() + chrono::Duration::minutes(password_token_expires_in);

    let password_reset_url = format!(
        "{}/resetpassword/{}",
        data.config.frontend_origin.to_owned(),
        password_reset_token
    );

    let email_instance = Email::new(user, password_reset_url, data.config.clone());
    if let Err(_) = email_instance
        .send_password_reset_token(password_token_expires_in)
        .await
    {
        let json_error = ErrorResponse {
            status: "fail",
            message: "Something bad happended while sending the password reset code".to_string(),
        };
        return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json_error)));
    }

    sqlx::query(
        "UPDATE users SET password_reset_token = $1, password_reset_at = $2 WHERE email = $3",
    )
    .bind(password_reset_token)
    .bind(&password_reset_at.clone())
    .bind(&email_address.clone())
    .execute(&data.db)
    .await
    .map_err(|e| {
        let json_error = ErrorResponse {
            status: "fail",
            message: format!("Error updating user: {}", e),
        };
        (StatusCode::INTERNAL_SERVER_ERROR, Json(json_error))
    })?;

    let response = serde_json::json!({
            "status": "success",
            "message": err_message
        }
    );

    Ok(Json(response))
}

pub async fn reset_password_handler(
    State(data): State<Arc<AppState>>,
    Path(password_reset_token): Path<String>,
    Json(body): Json<ResetPasswordSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    if body.password != body.password_confirm {
        let error_response = ErrorResponse {
            status: "fail",
            message: "Passwords do not match".to_string(),
        };
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
    }

    let user: User = sqlx::query_as(
        "SELECT * FROM users WHERE password_reset_token = $1 AND password_reset_at > $2",
    )
    .bind(password_reset_token)
    .bind(chrono::Utc::now())
    .fetch_optional(&data.db)
    .await
    .map_err(|e| {
        let error_response = ErrorResponse {
            status: "error",
            message: format!("Database error: {}", e),
        };
        (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
    })?
    .ok_or_else(|| {
        let error_response = ErrorResponse {
            status: "fail",
            message: "The password reset token is invalid or has expired".to_string(),
        };
        (StatusCode::FORBIDDEN, Json(error_response))
    })?;

    let salt = SaltString::generate(&mut OsRng);
    let hashed_password = Argon2::default()
        .hash_password(body.password.as_bytes(), &salt)
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "fail",
                message: format!("Error while hashing password: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })
        .map(|hash| hash.to_string())?;

    sqlx::query(
        "UPDATE users SET password = $1, password_reset_token = $2, password_reset_at = $3 WHERE email = $4",
    )
    .bind(hashed_password)
    .bind(Option::<String>::None)
    .bind(Option::<String>::None)
    .bind(&user.email.clone().to_ascii_lowercase())
    .execute(&data.db)
    .await
    .map_err(|e| {
        let json_error = ErrorResponse {
            status: "fail",
            message: format!("Error updating user: {}", e),
        };
        (StatusCode::INTERNAL_SERVER_ERROR, Json(json_error))
    })?;

    let cookie = Cookie::build(("token", ""))
        .path("/")
        .max_age(time::Duration::minutes(-1))
        .same_site(SameSite::Lax)
        .http_only(true);

    let mut response = Response::new(
        json!({"status": "success", "message": "Password data updated successfully"}).to_string(),
    );
    response
        .headers_mut()
        .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
    Ok(response)
}

pub async fn logout_handler() -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let cookie = Cookie::build(("token", ""))
        .path("/")
        .max_age(time::Duration::hours(-1))
        .same_site(SameSite::Lax)
        .http_only(true);

    let mut response = Response::new(json!({"status": "success"}).to_string());
    response
        .headers_mut()
        .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
    Ok(response)
}

pub async fn get_me_handler(
    Extension(user): Extension<User>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let filtered_user = FilteredUser::new_user(&user);

    let json_response = serde_json::json!({
        "status":  "success",
        "data": serde_json::json!({
            "user": filtered_user
        })
    });

    Ok(Json(json_response))
}

fn generate_random_string(length: usize) -> String {
    let rng = rand::thread_rng();
    let random_string: String = rng
        .sample_iter(&Alphanumeric)
        .take(length)
        .map(char::from)
        .collect();

    random_string
}

Registering the Forgot/Reset Password Routes

To define the forgot/reset password routes, you can follow these steps:

  1. Open the route.rs file located in the src directory.
  2. Replace the content of the file with the code provided below:

src/route.rs


use std::sync::Arc;

use axum::{
    middleware,
    routing::{get, patch, post},
    Router,
};

use crate::{
    handler::{
        forgot_password_handler, get_me_handler, health_checker_handler, login_user_handler,
        logout_handler, register_user_handler, reset_password_handler, verify_email_handler,
    },
    jwt_auth::auth,
    AppState,
};

pub fn create_router(app_state: Arc<AppState>) -> Router {
    Router::new()
        .route("/api/healthchecker", get(health_checker_handler))
        .route("/api/auth/register", post(register_user_handler))
        .route("/api/auth/login", post(login_user_handler))
        .route(
            "/api/auth/logout",
            get(logout_handler)
                .route_layer(middleware::from_fn_with_state(app_state.clone(), auth)),
        )
        .route(
            "/api/auth/verifyemail/:verification_code",
            get(verify_email_handler),
        )
        .route("/api/auth/forgotpassword", post(forgot_password_handler))
        .route(
            "/api/auth/resetpassword/:password_reset_token",
            patch(reset_password_handler),
        )
        .route(
            "/api/users/me",
            get(get_me_handler)
                .route_layer(middleware::from_fn_with_state(app_state.clone(), auth)),
        )
        .with_state(app_state)
}

In this code, the create_router function sets up the routes using the Axum framework. Here’s an overview of the routes:

  • /api/auth/forgotpassword – POST request handled by the forgot_password_handler.
  • /api/auth/resetpassword/:password_reset_token – PATCH request handled by the reset_password_handler.

The app_state is passed as state to the router to make it available to the handlers. Once you have replaced the content of the route.rs file, the forgot/reset password routes will be registered and ready to be invoked.

Registering the Axum Router and Configuring the Server

Here’s how the main.rs file should look. I’ve included it here so that you don’t have to go to the previous article or the source code to view it. You can see that we have imported all the modules into the main.rs file, connected to the SQLite database, set up CORS middleware, and started the Axum server.

src/main.rs


mod config;
mod email;
mod handler;
mod jwt_auth;
mod model;
mod response;
mod route;

use config::Config;
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
use std::sync::Arc;

use axum::http::{
    header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
    HeaderValue, Method,
};
use dotenv::dotenv;
use route::create_router;
use tower_http::cors::CorsLayer;

pub struct AppState {
    db: SqlitePool,
    config: Config,
}

#[tokio::main]
async fn main() {
    dotenv().ok();

    let config = Config::init();

    let pool = match SqlitePoolOptions::new()
        .max_connections(10)
        .connect(&config.database_url)
        .await
    {
        Ok(pool) => {
            println!("✅Connection to the database is successful!");
            pool
        }
        Err(err) => {
            println!("🔥 Failed to connect to the database: {:?}", err);
            std::process::exit(1);
        }
    };

    let cors = CorsLayer::new()
        .allow_origin("http://localhost:3000".parse::<HeaderValue>().unwrap())
        .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
        .allow_credentials(true)
        .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);

    let app = create_router(Arc::new(AppState {
        db: pool.clone(),
        config: config.clone(),
    }))
    .layer(cors);

    println!("🚀 Server started successfully");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

With all of this in place, you can start the Axum HTTP server by running the command cargo run, which will build the project and start the server on port 8000. Once the server is up and running, open an API testing client and send requests to test the endpoints.

Conclusion

And we are done! In this article, you have learned how to implement forgot/reset password functionality in Rust using the Axum web framework, SQLx, and SQLite. I hope you found this guide helpful and easy to follow. If you have any questions or feedback, please don’t hesitate to leave them in the comments section below. I’ll be delighted to respond promptly. Thank you for taking the time to read this article!