DEV Community

Cover image for Authentication system using rust (actix-web) and sveltekit - Token regeneration and password reset
John Owolabi Idogun
John Owolabi Idogun

Posted on

Authentication system using rust (actix-web) and sveltekit - Token regeneration and password reset

Introduction

So far, we have been building a reliable, dependable, secure and performant authentication backend service with a resilient frontend application. As awesome as the application has turned out to be, there are still some defects. Currently, if a user fails to confirm the email address with which registration was made within the stipulated time of token expiration, such a user will forever be barred from accessing some resources that are private or only accessible to authentic or bonafide members of our application. Also, if a user's password is forgotten, there is currently no avenue to reset and subsequently regain access. We need to provide a feature that allows users to regenerate their expired tokens so that their accounts can be verified. Another feature that takes users through a seamless password reset process will also be added. All in this article. Let's go!

Source code

The source code for this series is hosted on GitHub via:

GitHub logo Sirneij / rust-auth

A fullstack authentication system using rust, sveltekit, and Typescript

rust-auth

A full-stack secure and performant authentication system using rust's Actix web and JavaScript's SvelteKit.

This application resulted from this series of articles and it's currently live here(I have disabled the backend from running live).

Run locally

You can run the application locally by first cloning it:

~/$ git clone https://github.com/Sirneij/rust-auth.git
Enter fullscreen mode Exit fullscreen mode

After that, change directory into each subdirectory: backend and frontend in different terminals. Then following the instructions in each subdirectory to run them.




Implementation

You can get the overview of the code for this article on github.

Step 1: Write token regeneration token

Let's first provide users with the option of regenerating expired tokens meant for validating users. The logic will only allow unverified users:

// backend/src/routes/users/generate_new_token.rs
use sqlx::Row;

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

#[derive(serde::Serialize, serde::Deserialize)]
struct SimpleUser {
    id: uuid::Uuid,
    email: String,
    first_name: String,
    last_name: String,
    is_active: bool,
    is_staff: bool,
    is_superuser: bool,
    thumbnail: Option<String>,
    date_joined: chrono::DateTime<chrono::Utc>,
}

#[tracing::instrument(name = "Regenerate token for a user", skip(pool, redis_pool))]
#[actix_web::post("/regenerate-token/")]
pub async fn regenerate_token(
    pool: actix_web::web::Data<sqlx::postgres::PgPool>,
    user_email: actix_web::web::Json<UserEmail>,
    redis_pool: actix_web::web::Data<deadpool_redis::Pool>,
) -> actix_web::HttpResponse {
    match get_user_who_is_not_active(&pool, &user_email.email).await {
        Ok(visible_user_detail) => {
            let mut redis_con = redis_pool
                .get()
                .await
                .map_err(|e| {
                    tracing::event!(target: "backend", tracing::Level::ERROR, "{}", e);
                    actix_web::HttpResponse::InternalServerError().json(
                        crate::types::ErrorResponse {
                            error: "We cannot activate your account at the moment".to_string(),
                        },
                    )
                })
                .expect("Redis connection cannot be gotten.");
            crate::utils::send_multipart_email(
                "RustAuth - Let's get you verified".to_string(),
                visible_user_detail.id,
                visible_user_detail.email,
                visible_user_detail.first_name,
                visible_user_detail.last_name,
                "verification_email.html",
                &mut redis_con,
            )
            .await
            .unwrap();
            actix_web::HttpResponse::Ok().json(crate::types::SuccessResponse {
                message: "Account activation link has been sent to your email address. Kindly take action before its expiration".to_string(),
            })
        }
        Err(e) => {
            tracing::event!(target: "sqlx",tracing::Level::ERROR, "User not found:{:#?}", e);
            actix_web::HttpResponse::NotFound().json(crate::types::ErrorResponse {
                error: "A user with this e-mail address does not exist. If you registered with this email, ensure you haven't activated it yet. You can check by logging in".to_string(),
            })
        }
    }
}

#[tracing::instrument(name = "Getting a user from DB who isn't active yet.", skip(pool, email),fields(user_email = %email))]
async fn get_user_who_is_not_active(
    pool: &sqlx::postgres::PgPool,
    email: &String,
) -> Result<SimpleUser, sqlx::Error> {
    match sqlx::query("SELECT id, email, first_name, last_name, password, is_active, is_staff, is_superuser, date_joined, thumbnail FROM users WHERE email = $1 AND is_active=false")
        .bind(email)
        .map(|row: sqlx::postgres::PgRow| SimpleUser {
            id: row.get("id"),
            email: row.get("email"),
            first_name: row.get("first_name"),
            last_name: row.get("last_name"),
            is_active: row.get("is_active"),
            is_staff: row.get("is_staff"),
            is_superuser: row.get("is_superuser"),
            thumbnail: row.get("thumbnail"),
            date_joined: row.get("date_joined"),
        })
        .fetch_one(pool)
        .await
    {
        Ok(user) => Ok(user),
        Err(e) => {
            tracing::event!(target: "sqlx",tracing::Level::ERROR, "User not found in DB: {:#?}", e);
            Err(e)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It is a pretty simple logic. We're quite familiar with this pattern now. The handler requires the email of such a user and with it, an INACTIVE user is retrieved from the DB. If a user is found, an email with a new token is sent to such a user. Otherwise, an error response is returned. Pretty basic.

Ensure you add this handler to our auth_routes_config.

Step 2: Create and write a password reset submodule

Under the users route, let's create a password_change submodule that houses the logic of the password reset logic. In the submodule, we should have three files whose functions are succinctly discussed in the table below:

File name Route Function
request_change.rs /password-change/request-password-change/ Password change request route. This starts the entire process of resetting users' passwords. The user's email is required to start the process and a mail is sent to the address to start such a process
confirm_change_request.rs /password-change/confirm/change-password/ This route is hit whenever the user clicks the link sent to such a user's email address. It verifies the token sent to the user and if authentic, issues a new token and redirects users to the frontend where they can input their new passwords. The newly generated token is important for the next handler.
change.rs /password-change/change-user-password/ This is the route that REALLY changes users' passwords. It uses the token generated by the previous route to get the ID of the user requesting such a change. After determining the user, the password is hashed and the user's password is updated.

Having explained the high-level overview of the files, below are their contents:

// backend/src/routes/users/password_change/request_change.rs
#[derive(serde::Deserialize, Debug)]
pub struct UserEmail {
    email: String,
}

#[tracing::instrument(name = "Requesting a password change", skip(pool, redis_pool))]
#[actix_web::post("/request-password-change/")]
pub async fn request_password_change(
    pool: actix_web::web::Data<sqlx::postgres::PgPool>,
    user_email: actix_web::web::Json<UserEmail>,
    redis_pool: actix_web::web::Data<deadpool_redis::Pool>,
) -> actix_web::HttpResponse {
    let settings = crate::settings::get_settings().expect("Failed to read settings.");
    match crate::utils::get_active_user_from_db(Some(&pool), None, None, Some(&user_email.0.email))
        .await
    {
        Ok(visible_user_detail) => {
            let mut redis_con = redis_pool
                .get()
                .await
                .map_err(|e| {
                    tracing::event!(target: "backend", tracing::Level::ERROR, "{}", e);
                    actix_web::HttpResponse::InternalServerError().json(
                        crate::types::ErrorResponse {
                            error: "Something happened. Please try again".to_string(),
                        },
                    )
                })
                .expect("Redis connection cannot be gotten.");
            crate::utils::send_multipart_email(
                "RustAuth - Password Reset Instructions".to_string(),
                visible_user_detail.id,
                visible_user_detail.email,
                visible_user_detail.first_name,
                visible_user_detail.last_name,
                "password_reset_email.html",
                &mut redis_con,
            )
            .await
            .unwrap();
            actix_web::HttpResponse::Ok().json(crate::types::SuccessResponse {
                message: "Password reset instructions have been sent to your email address. Kindly take action before its expiration".to_string(),
            })
        }
        Err(e) => {
            tracing::event!(target: "sqlx",tracing::Level::ERROR, "User not found:{:#?}", e);
            actix_web::HttpResponse::NotFound().json(crate::types::ErrorResponse {
                error: format!("An active user with this e-mail address does not exist. If you registered with this email, ensure you have activated your account. You can check by logging in. If you have not activated it, visit {}/auth/regenerate-token to regenerate the token that will allow you activate your account.", settings.frontend_url),
            })
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// backend/src/routes/users/password_change/confirm_change_request.rs
#[derive(serde::Deserialize)]
pub struct Parameters {
    token: String,
}

#[tracing::instrument(name = "Confirming change password token", skip(params, redis_pool))]
#[actix_web::get("/confirm/change-password/")]
pub async fn confirm_change_password_token(
    params: actix_web::web::Query<Parameters>,
    redis_pool: actix_web::web::Data<deadpool_redis::Pool>,
) -> actix_web::HttpResponse {
    let settings = crate::settings::get_settings().expect("Failed to read settings.");

    let mut redis_con = redis_pool
        .get()
        .await
        .map_err(|e| {
            tracing::event!(target: "backend", tracing::Level::ERROR, "{}", e);

            actix_web::HttpResponse::SeeOther()
                .insert_header((
                    actix_web::http::header::LOCATION,
                    format!(
                        "{}/auth/error?reason=Something unexpected happened. Kindly try again",
                        settings.frontend_url
                    ),
                ))
                .finish()
        })
        .expect("Redis connection cannot be gotten.");

    let confirmation_token = match crate::utils::verify_confirmation_token_pasetor(
        params.token.clone(),
        &mut redis_con,
        None,
    )
    .await
    {
        Ok(token) => token,
        Err(e) => {
            tracing::event!(target: "backend",tracing::Level::ERROR, "{:#?}", e);

            return actix_web::HttpResponse::SeeOther()
                .insert_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/auth/error?reason=It appears that your password request token has expired or previously used", settings.frontend_url, ),
                ))
                .finish();
        }
    };

    let issued_token = match crate::utils::issue_confirmation_token_pasetors(
        confirmation_token.user_id,
        &mut redis_con,
        Some(true),
    )
    .await
    {
        Ok(t) => t,
        Err(e) => {
            tracing::event!(target: "backend", tracing::Level::ERROR, "{}", e);
            return actix_web::HttpResponse::SeeOther()
                .insert_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/auth/error?reason={}", settings.frontend_url, e),
                ))
                .json(crate::types::ErrorResponse {
                    error: format!("{}", e),
                });
        }
    };

    actix_web::HttpResponse::SeeOther()
        .insert_header((
            actix_web::http::header::LOCATION,
            format!(
                "{}/auth/password/change-password?token={}",
                settings.frontend_url, issued_token
            ),
        ))
        .finish()
}
Enter fullscreen mode Exit fullscreen mode
// backend/src/routes/users/password_change/change.rs
#[derive(serde::Deserialize)]
pub struct NewPassword {
    token: String,
    password: String,
}

#[tracing::instrument(
    name = "Changing user's password",
    skip(pool, new_password, redis_pool)
)]
#[actix_web::post("/change-user-password/")]
pub async fn change_user_password(
    pool: actix_web::web::Data<sqlx::postgres::PgPool>,
    new_password: actix_web::web::Json<NewPassword>,
    redis_pool: actix_web::web::Data<deadpool_redis::Pool>,
) -> actix_web::HttpResponse {
    let settings = crate::settings::get_settings().expect("Failed to read settings.");

    let mut redis_con = redis_pool
        .get()
        .await
        .map_err(|e| {
            tracing::event!(target: "backend", tracing::Level::ERROR, "{}", e);

            actix_web::HttpResponse::SeeOther()
                .insert_header((
                    actix_web::http::header::LOCATION,
                    format!(
                        "{}/auth/error?reason=We cannot activate your account at the moment",
                        settings.frontend_url
                    ),
                ))
                .finish()
        })
        .expect("Redis connection cannot be gotten.");

    let confirmation_token = match crate::utils::verify_confirmation_token_pasetor(
        new_password.0.token,
        &mut redis_con,
        Some(true),
    )
    .await
    {
        Ok(token) => token,
        Err(e) => {
            tracing::event!(target: "backend",tracing::Level::ERROR, "{:#?}", e);
            return actix_web::HttpResponse::BadRequest().json(crate::types::ErrorResponse {
                error: "It appears that your password request token has expired or previously used"
                    .to_string(),
            });
        }
    };

    let new_user_password = crate::utils::hash(new_password.0.password.as_bytes()).await;

    match update_user_password_in_db(&pool, &new_user_password, confirmation_token.user_id).await {
        Ok(_) => {
            tracing::event!(target: "backend",tracing::Level::INFO, "User password updated successfully");
            actix_web::HttpResponse::Ok().json(crate::types::SuccessResponse {
                message: "Your password has been changed successfully. Kindly login with the new password".to_string(),
            })
        }
        Err(e) => {
            tracing::event!(target: "backend",tracing::Level::ERROR, "Failed to change user password: {:#?}", e);
            actix_web::HttpResponse::BadRequest().json(crate::types::ErrorResponse {
                error: "Sorry, we could not change your password this time. Please try again."
                    .to_string(),
            })
        }
    }
}

#[tracing::instrument(name = "Updating user password in the DB.", skip(pool, new_password))]
async fn update_user_password_in_db(
    pool: &sqlx::postgres::PgPool,
    new_password: &String,
    user_id: uuid::Uuid,
) -> Result<bool, sqlx::Error> {
    match sqlx::query("UPDATE users SET password = $1 WHERE id = $2")
        .bind(new_password)
        .bind(user_id)
        .execute(pool)
        .await
    {
        Ok(r) => {
            tracing::event!(target: "sqlx", tracing::Level::INFO, "User password has been updated successfully in the DB: {:?}", r);
            Ok(true)
        }
        Err(e) => {
            tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to update user password in the DB: {:#?}", e);
            Err(e)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

They do exactly what we said they were going to do. The first sends a mail using a template, password_reset_email.html, whose content is:

<!-- backend/templates/password_reset_email.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{{ title }}</title>
  </head>

  <body>
    <table
      style="
        max-width: 555px;
        width: 100%;
        font-family: 'Open Sans', Segoe, 'Segoe UI', 'DejaVu Sans',
          'Trebuchet MS', Verdana, sans-serif;
        background: #fff;
        font-size: 13px;
        color: #323232;
      "
      cellspacing="0"
      cellpadding="0"
      border="0"
      bgcolor="#ffffff"
      align="center"
    >
      <tbody>
        <tr>
          <td align="left">
            <h1 style="text-align: center">
              <span style="font-size: 15px">
                <strong>{{ title }}</strong>
              </span>
            </h1>

            <p>
              Your request to reset your password was submitted. If you did not
              make this request, simply ignore this email. If you did make this
              request just click the button below:
            </p>

            <table
              style="
                max-width: 555px;
                width: 100%;
                font-family: 'Open Sans', arial, sans-serif;
                font-size: 13px;
                color: #323232;
              "
              cellspacing="0"
              cellpadding="0"
              border="0"
              bgcolor="#ffffff"
              align="center"
            >
              <tbody>
                <tr>
                  <td height="10">&nbsp;</td>
                </tr>
                <tr>
                  <td style="text-align: center">
                    <a
                      href="{{ confirmation_link }}"
                      style="
                        color: #fff;
                        background-color: hsla(199, 69%, 84%, 1);
                        width: 320px;
                        font-size: 16px;
                        border-radius: 3px;
                        line-height: 44px;
                        height: 44px;
                        font-family: 'Open Sans', Arial, helvetica, sans-serif;
                        text-align: center;
                        text-decoration: none;
                        display: inline-block;
                      "
                      target="_blank"
                      data-saferedirecturl="https://www.google.com/url?q={{ confirmation_link }}"
                    >
                      <span style="color: #000000">
                        <strong>Change password</strong>
                      </span>
                    </a>
                  </td>
                </tr>
              </tbody>
            </table>

            <table
              style="
                max-width: 555px;
                width: 100%;
                font-family: 'Open Sans', arial, sans-serif;
                font-size: 13px;
                color: #323232;
              "
              cellspacing="0"
              cellpadding="0"
              border="0"
              bgcolor="#ffffff"
              align="center"
            >
              <tbody>
                <tr>
                  <td height="10">&nbsp;</td>
                </tr>
                <tr>
                  <td align="left">
                    <p align="center">&nbsp;</p>
                    If the above button doesn't work, try copying and pasting
                    the link below into your browser. If you continue to
                    experience problems, please contact us.
                    <br />
                    {{ confirmation_link }}
                    <br />
                  </td>
                </tr>
                <tr>
                  <td>
                    <p align="center">&nbsp;</p>
                    <br />
                    <p style="padding-bottom: 15px; margin: 0">
                      Kindly note that this link will expire in
                      <strong>{{expiration_time}} minutes</strong>. The exact
                      expiration date and time is:
                      <strong>{{ exact_time }}</strong>.
                    </p>
                  </td>
                </tr>
              </tbody>
            </table>
          </td>
        </tr>
      </tbody>
    </table>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

We are still using MiniJinja as our templating engine.

NOTE: You need to build the password_change routes configuration in backend/src/routes/users/password_change/mod.rs. Then connect the config to auth_routes_config. Also, update the confirmation_link in backend/src/utils/emails.rs. This article has all of them effected. Please take a look.

Step 3: Write the frontend logic

Having built out the backend service, it's now the turn of the frontend app. Starting with frontend/src/routes/auth/regenerate-token/+page.svelte:

<!-- frontend/src/routes/auth/regenerate-token/+page.svelte -->
<script lang="ts">
    import { page } from '$app/stores';
    import { notification } from '$lib/stores/notification.store';
    import { happyEmoji, sadEmoji } from '$lib/utils/constant';
    import { receive, send } from '$lib/utils/helpers/animate.crossfade';
    import { scale } from 'svelte/transition';
    import type { ActionData, SubmitFunction } from './$types';
    import { loading } from '$lib/stores/loading.store';
    import { applyAction, enhance } from '$app/forms';
    export let form: ActionData;
    let reason = '';
    if ($page.url.search) {
        reason = $page.url.search.split('=')[1].replaceAll('%20', ' ');
    }
    if (reason) {
        $notification = {
            message: `${reason} ${sadEmoji}...`,
            colorName: 'rose'
        };
    }
    const handleGenerate: SubmitFunction = async () => {
        loading.setLoading(true, 'Please wait while we regenerate your token...');
        return async ({ result }) => {
            loading.setLoading(false);
            if (result.type === 'success' || result.type === 'redirect') {
                $notification = {
                    message: `You have successfully regenerated a new token ${happyEmoji}...`,
                    colorName: `emerald`
                };
            }
            await applyAction(result);
        };
    };
</script>

<svelte:head>
    <title>Auth - Regenerate Token | Actix Web & SvelteKit</title>
</svelte:head>

<div class="flex items-center justify-center h-[60vh]">
    <form class="form" method="POST" use:enhance={handleGenerate}>
        <h1 style="text-align:center">Regenerate token</h1>
        {#if form?.errors}
            {#each form?.errors as error (error.id)}
                <p
                    class="text-center text-rose-600"
                    in:receive={{ key: error.id }}
                    out:send={{ key: error.id }}
                >
                    {error.error}
                </p>
            {/each}
        {/if}
        <input type="email" name="email" id="email" placeholder="Registered e-mail address" required />
        {#if form?.fieldsError && form?.fieldsError.email}
            <span class="text-center text-rose-600 text-xs" transition:scale|local={{ start: 0.7 }}>
                {form?.fieldsError.email}
            </span>
        {/if}

        <button type="submit" class="btn">Regenerate</button>
    </form>
</div>
Enter fullscreen mode Exit fullscreen mode

Just a single input element of type email. As we introduced in the last article, we need to write a complementary form action in frontend/src/routes/auth/regenerate-token/+page.server.ts:

// frontend/src/routes/auth/regenerate-token/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { BASE_API_URI } from '$lib/utils/constant';
import type { CustomError } from '$lib/utils/types';
import { isValidEmail } from '$lib/utils/helpers/input.validation';
import { isEmpty } from '$lib/utils/helpers/test.object.empty';

export const load: PageServerLoad = async () => {
    return {};
};

export const actions: Actions = {
    default: async ({ fetch, request }) => {
        const formData = await request.formData();
        const email = String(formData.get('email'));

        // Some validations
        const fieldsError: Record<string, string> = {};
        if (!isValidEmail(email)) {
            fieldsError.email = 'That email address is invalid.';
        }

        if (!isEmpty(fieldsError)) {
            return fail(400, { fieldsError: fieldsError });
        }

        const requestInitOptions: RequestInit = {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({ email: email })
        };

        const res = await fetch(`${BASE_API_URI}/users/regenerate-token/`, requestInitOptions);

        if (!res.ok) {
            const response = await res.json();
            const errors: Array<CustomError> = [];
            errors.push({ error: response.error, id: 0 });
            return fail(400, { errors: errors });
        }

        const response = await res.json();

        // redirect the user
        throw redirect(302, `/auth/confirming?message=${response.message}`);
    }
};
Enter fullscreen mode Exit fullscreen mode

Just what we are used to. That concludes the regenerate token frontend logic. This same logic was used for the password change request and the actual change. Their codes are in this article's GitHub repo.

That's it! All the application's requirements have now been fully implemented. In the next articles, we'll talk about automated testing. See you...

Outro

Enjoyed this article? Consider contacting me for a job, something worthwhile or buying a coffee ☕. You can also connect with/follow me on LinkedIn and Twitter. It isn't bad if you help share this article for wider coverage. I will appreciate it...

Top comments (0)