Introduction
Previously, we successfully connected our front-end application with the back-end system. There was an awesome feeling afterwards! However, we have not fully utilized the capabilities of our backend systems yet. We haven't enabled users' login and logout at the front-end. Also, to follow some best practices, we will be containerizing our application to run on any infrastructure and for easy deployment. Specifically, we'll be deploying our backend system on fly.io using an optimized Docker file. 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. Some changes were also made directly to the repo's main branch. We will cover them.
Step 1: Login and Logout users
Having set the pace and foundation for the frontend application in the previous article by registering users directly via the app, it's time to leverage that and extend it to log users in and out:
<!--frontend/src/routes/auth/login/+page.svelte-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { loading } from '$lib/stores/loading.store';
import { notification } from '$lib/stores/notification.store';
import { isAuthenticated, loggedInUser } from '$lib/stores/user.store';
import { BASE_API_URI, happyEmoji } from '$lib/utils/constant';
import { post } from '$lib/utils/requests/posts.requests';
import type { CustomError, User } from '$lib/utils/types';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let email = '',
password = '',
errors: Array<CustomError> = [];
const handleLogin = async () => {
loading.setLoading(true, 'Please wait while we log you in...');
const [res, err] = await post(
$page.data.fetch,
`${BASE_API_URI}/users/login/`,
{
email: email,
password: password
},
'include'
);
if (err.length > 0) {
loading.setLoading(false);
errors = err;
} else {
loading.setLoading(false);
const response: User = res as User;
$loggedInUser = {
id: response['id'],
email: response['email'],
first_name: response['first_name'],
last_name: response['last_name'],
is_staff: response['is_staff'],
is_superuser: response['is_superuser'],
thumbnail: response['thumbnail']
};
$isAuthenticated = true;
$notification = {
message: `Login successfull ${happyEmoji}...`,
colorName: `green`
};
let nextPage = $page.url.search.split('=')[1];
if ($page.url.hash) {
nextPage = `${nextPage}${$page.url.hash}`;
}
await goto(nextPage || '/', { noScroll: true });
}
};
</script>
<svelte:head>
<title>Auth - Login | Actix Web & SvelteKit</title>
</svelte:head>
<div class="flex items-center justify-center h-[60vh]">
<form
class="w-11/12 md:w-2/3 lg:w-1/3 rounded-xl flex flex-col items-center align-middle bg-slate-800 py-4"
on:submit|preventDefault={handleLogin}
>
<h1 class="text-center text-2xl font-bold text-sky-400 mb-6">Login</h1>
{#if errors}
{#each errors as error (error.id)}
<p
class="text-center text-rose-600"
transition:scale|local={{ start: 0.7 }}
animate:flip={{ duration: 200 }}
>
{error.error}
</p>
{/each}
{/if}
<div class="w-3/4 mb-2">
<input
type="email"
name="email"
id="email"
bind:value={email}
class="w-full text-sky-500 placeholder:text-slate-600 border-none focus:ring-0 bg-main-color focus:outline-none py-2 px-3 rounded"
placeholder="Email address"
required
/>
</div>
<div class="w-3/4 mb-6">
<input
type="password"
name="password"
id="password"
bind:value={password}
class="w-full text-sky-500 placeholder:text-slate-600 border-none focus:ring-0 bg-main-color focus:outline-none py-2 px-3 rounded"
placeholder="Password"
required
/>
</div>
<div class="w-3/4 flex flex-row justify-between">
<div class=" flex items-center gap-x-1">
<input type="checkbox" name="remember" id="" class=" w-4 h-4" />
<label for="" class="text-sm text-sky-400">Remember me</label>
</div>
<div>
<a href={null} class="text-sm underline text-slate-400 hover:text-sky-400">Forgot?</a>
</div>
</div>
<div class="w-3/4 mt-4">
<button
type="submit"
class="py-2 bg-sky-800 w-full rounded text-blue-50 font-bold hover:bg-sky-700"
>
Login
</button>
</div>
<div class="w-3/4 flex flex-row justify-center mt-1">
<span class="text-sm text-sky-400">
No account?
<a href="/auth/register" class="ml-2 text-slate-400 underline hover:text-sky-400">
Create an account.
</a>
</span>
</div>
</form>
</div>
The login process is the same thing as the registration process. The only differences are in the number of input elements, and the contents of handleLogin
function. In the function, we used our login URL endpoint, and set credentials
to include
. The latter is extremely important for our cookies to be saved in the browser and available for further requests to the server. If we have a successful login process, we save the user details returned from the server in our loggedInUser
store and isAuthenticated
is set to true
. This is to appropriately change the UI based on the logged-in user. Lastly, we check whether or not there's a redirect path and go to such a path or the home page. What's a redirect path, you ask? Take for instance, we have a protected page, /auth/about
, that only authenticated users can access. We can redirect users to the login page and after login, redirect them back to where they wanted to access before we forced them to log in. We'll see this in action later.
That's it for login. Next is logout:
// frontend/src/lib/utils/requests/logout.requests.ts
import { goto } from '$app/navigation';
import { notification } from '$lib/stores/notification.store';
import { isAuthenticated } from '$lib/stores/user.store';
import { BASE_API_URI, happyEmoji, sadEmoji } from '../constant';
import type { ApiResponse } from '../types';
import { post } from './posts.requests';
/**
* Logs a user out of the application.
* @file lib/utils/requests/logout.requests.ts
* @param {typeof fetch} svelteKitFetch - Fetch object from sveltekit
* @param {string} [redirectUrl='/auth/login'] - URL to redirect to after logout.
*/
export const logout = async (svelteKitFetch: typeof fetch, redirectUrl = '/auth/login') => {
const [res, err] = await post(
svelteKitFetch,
`${BASE_API_URI}/users/logout/`,
undefined,
'include'
);
if (err.length > 0) {
notification.set({
message: `${err[0].error} ${sadEmoji}...`,
colorName: 'rose'
});
} else {
const response: ApiResponse = res;
notification.set({
message: `${response.message} ${happyEmoji}...`,
colorName: 'green'
});
isAuthenticated.set(false);
if (redirectUrl !== '') {
await goto(redirectUrl);
}
}
};
We wrote a custom logout function which uses our post
under the hood. It's a simple function. Then we went to our app's header, frontend/src/lib/component/Header/Header.svelte
, and used the function:
<!--frontend/src/lib/component/Header/Header.svelte-->
<script lang="ts">
import { page } from '$app/stores';
import { isAuthenticated, loggedInUser } from '$lib/stores/user.store';
import JohnImage from '$lib/svgs/john.svg';
import Avatar from '$lib/svgs/teamavatar.png';
import { logout } from '$lib/utils/requests/logout.requests';
</script>
<header aria-label="Page Header" class="mb-6">
<nav class="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4">
<a
href="https://github.com/Sirneij"
class="block shrink-0 rounded-full bg-white p-2.5 text-gray-600 shadow-sm hover:text-gray-700"
>
<span class="sr-only">Programmer</span>
<img src={JohnImage} alt="John Idogun" class="h-6 w-6 rounded-full object-cover" />
</a>
</div>
<span aria-hidden="true" class="block h-6 w-px rounded-full bg-gray-200" />
<a
href="/"
class="block shrink-0 {$page.url.pathname === `/`
? 'text-sky-500'
: 'text-white'} hover:text-sky-400"
>
/
</a>
{#if !$isAuthenticated}
<a
href="/auth/login"
class="block shrink-0 {$page.url.pathname === `/auth/login`
? 'text-sky-500'
: 'text-white'} hover:text-sky-400"
>
Login
</a>
{:else}
<a href="/auth/about" class="block shrink-0">
<span class="sr-only">{$loggedInUser.first_name} Profile</span>
<img
alt={$loggedInUser.first_name}
src={$loggedInUser.thumbnail ? $loggedInUser.thumbnail : Avatar}
class="h-10 w-10 rounded-full object-cover"
/>
</a>
<button
type="button"
class="text-white hover:text-sky-400"
on:click={() => logout($page.data.fetch)}
>
Logout
</button>
{/if}
</div>
</nav>
</header>
The Logout
button was used. In the header, we also made it smart to detect when a user is authenticated or not and display appropriate links. Based on your web address, we also set active links using JavaScript's ternary operator.
That's it! Let's get to containerization.
Step 2: Dockerize our backend application
In modern times, containerization is the new normal. We can't do less here! First, we need to do some safekeeping since we are already thinking about production.
Many hosting platforms issue your database configuration in the form of a URL. For PostgreSQL, the URL looks like this:
postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
Because of this, let's delete the database settings we have and convert it into a DATABASE_URL
which is set in our .env
file. We will also remove the URI
under redis
settings since that will also be replaced with REDIS_URL
. Having done that, our build
method in backend/src/startup.rs
will be modified:
...
let connection_pool = if let Some(pool) = test_pool {
pool
} else {
let db_url = std::env::var("DATABASE_URL").expect("Failed to get DATABASE_URL.");
match sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
{
Ok(pool) => pool,
Err(e) => {
tracing::event!(target: "sqlx",tracing::Level::ERROR, "Couldn't establish DB connection!: {:#?}", e);
panic!("Couldn't establish DB connection!")
}
}
};
...
We are getting db_url
from our environment variable. We then use it to connect to our database. Pretty neat! Now, you can safely delete get_connection_pool
.
In the run
method, we also obtain our redis_url
from REDIS_URL
. Also note that we now use redis to store our cookies:
# backend/Cargo.toml
...
actix-session = { version = "0.7", features = [
"redis-rs-session",
"redis-rs-tls-session",
] }
...
We discarded CookieSessionStore
because of its limitations.
There were other minor changes in the run
function inside backend/src/startup.rs
such as removing the settings.debug
condition in the SessionMiddleware configuration and some others. The repo has the updated code.
Now to containerization proper. For our application, we will be using a Dockerfile
to deploy it. We, therefore, need to ensure that the build resulting from the file is as small as possible. Hence, we separate the build process into two stages: builder
and runtime
. Doing it this way makes the resulting build minimal in size. Our app's Dockerfile
looks like this:
# backend/Dockerfile
# Builder stage
FROM rust:1.63.0 AS builder
WORKDIR /app
RUN apt update && apt install lld clang -y
COPY . .
RUN cargo build --release
# Runtime stage
FROM debian:bullseye-slim AS runtime
WORKDIR /app
# Install OpenSSL - it is dynamically linked by some of our dependencies
# Install ca-certificates - it is needed to verify TLS certificates
# when establishing HTTPS connections
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
# Clean up
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/backend backend
# We need the settings file at runtime!
COPY settings settings
COPY templates templates
ENV APP_ENVIRONMENT production
ENV APP_DEBUG false
ENTRYPOINT ["./backend"]
We only did what mattered. At the "builder stage", we aim to compile our app. To do that, all we need is a Rust environment. Having done that we moved to the "runtime stage". At this stage, we discard the previous Rust environment, because it is not needed again, and use a small "OS" that can successfully run the executable made available by the "builder stage", copied over using COPY --from=builder /app/target/release/backend backend
. We also remove unnecessary files that normally come with Debian OS that are not useful for our app. Other important folders, viz: settings, and templates, were also copied and we told docker to use the production
environment. Our app's entry point is the backend
executable made available by our build process.
This Dockerfile
is remarkably slim, less than 110 MiB
. However, we still have an issue, inherent to Rust. Considerable compile time! Applications in Rust with a fair amount of dependencies like ours take time to compile every time it is built. We can employ caching here so that once built, it won't take time to rebuild unless we add packages or modify existing ones. To do this, the Rust ecosystem comes to the rescue once again. We will use a crate called cargo-chef. You don't need to install it in your app. We just need to change our Dockerfile to use it:
# backend/Dockerfile
FROM lukemathwalker/cargo-chef:latest-rust-latest as chef
WORKDIR /app
RUN apt update && apt install lld clang -y
FROM chef as planner
COPY . .
# Compute a lock-like file for our project
RUN cargo chef prepare --recipe-path recipe.json
FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
# Build our project dependencies, not our application!
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
# Build our project
RUN cargo build --release --bin backend
FROM debian:bullseye-slim AS runtime
WORKDIR /app
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
# Clean up
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/backend backend
COPY settings settings
COPY templates templates
ENV APP_ENVIRONMENT production
ENV APP_DEBUG false
ENTRYPOINT ["./backend"]
Almost the same thing as our previous file aside from some specifics. Cargo-chef's docs does justice to it.
Now to deployment!
Step 3: Deploy the rust app to fly.io freely
fly.io offers us a free hosting service, enough to test your application: 1 CPU, 256 MiB
. You are also given a free PostgreSQL database of 1GB size and Redis. That's just enough for testing! Though fly.io does not support rust natively, using Docker, we can get it deployed successfully! We will be using these guide and guide to achieve it.
According to this guide, you can deploy your app in 3 steps:
- Install fly.io CLI, called flyctl.
- Login to your account using
fly auth login
if you have an account or register usingfly auth signup
if otherwise. - Create, configure, and deploy your application application using
fly launch
.
Kindly do the first two before proceeding.
Having installed the CLI and created or logged in to your account, let's deploy our app!
We will be using this other guide to do that.
At the root of our backend app, where our Dockerfile
resides, open your terminal and issue this command:
~/rust-auth/backend$ fly launch
It detects your Dockerfile automatically! You should see something like:
? App Name (leave blank to use an auto-generated name):
? Select organization: Mark Ericksen (personal)
? Select region: lax (Los Angeles, California (US))
Created app weathered-wave-1020 in organization personal
Wrote config file fly.toml
? Would you like to deploy now? (y/N)
It first asks for your app's name. I put auth-actix-web-sveltekit
. If you don't provide a name, it'll generate one for you. I accepted other defaults. It will then ask if you would like to provision a PostgreSQL database and Redis for your app. Please type y
. It will provision them and display their credentials. It will also help you set DATABASE_URL
and REDIS_URL
automatically.
NOTE: Please, ensure you copy the Database credentials shown to you because you won't get to see them again.
After that, a file, fly.toml
, will be generated for you. You will then be asked if you want to deploy your app now. I chose N
because I needed to set some environment variables for my emails and secret keys.
Now, to set your email credentials for SMTP, do:
~/rust-auth/backend$ flyctl secrets set APP_EMAIL__HOST_USER_PASSWORD=<your_password>
~/rust-auth/backend$ flyctl secrets set APP_EMAIL__HOST_USER=<your_email_address>
Any environment variable set with the secrets
command remains secret! You can use that to set APP_SECRET__SECRET_KEY
and co as well but I opted to do it in fly.toml
file instead for demonstration.
In your backend/fly.toml
file, create an [env]
section and make it look like this:
# backend/fly.toml
...
[env]
APP_SECRET__SECRET_KEY = "KaPdSgVkYp3s6v9y$B&E)H+MbQeThWm6"
APP_SECRET__HMAC_SECRET = "001448809fd614fcf2b19a6caeac40834f0771a0af0ef0849280e8042fd95918"
APP_SECRET__TOKEN_EXPIRATION = "15"
APP_APPLICATION__BASE_URL = "https://auth-actix-web-sveltekit.fly.dev"
APP_APPLICATION__PORT = "8080"
APP_FRONTEND_URL = "https://rust-auth.vercel.app"
I set secret_key
and hmac_secret
as well as token_expiration
. Our app's base_url
is now https://${APP_NAME}.fly.dev
. Please ensure you set the app's port
to the internal_port
at the top of the fly.toml
file. If not, your app may not go live. I have already deployed the frontend app to vercel and set the URL as frontend_url
. Deploying a SvelteKit app on Vercel is extremely simple and SvelteKit docs has a guide. Before deploying your front-end, ensure you set your VITE_BASE_API_URI_PROD
to https://${APP_NAME}.fly.dev
.
Congratulations!!! Your application is live!!! ππππΊπΊπΊ
I will see you in the next article...
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)