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:
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
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)
}
}
}
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),
})
}
}
}
// 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()
}
// 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)
}
}
}
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"> </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"> </td>
</tr>
<tr>
<td align="left">
<p align="center"> </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"> </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>
We are still using MiniJinja as our templating engine.
NOTE: You need to build the
password_change
routes configuration inbackend/src/routes/users/password_change/mod.rs
. Then connect the config toauth_routes_config
. Also, update theconfirmation_link
inbackend/src/utils/emails.rs
. This article has all of them affected. 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>
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}`);
}
};
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? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, healthcare, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn and Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!
Top comments (0)