DEV Community

Cover image for Authentication system using rust (actix-web) and sveltekit - Log in/out, Dockerize and Deploy on fly.io
John Owolabi Idogun
John Owolabi Idogun

Posted on • Edited on

Authentication system using rust (actix-web) and sveltekit - Log in/out, Dockerize and Deploy on fly.io

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:

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. 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>
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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!")
        }
    }
};
...
Enter fullscreen mode Exit fullscreen mode

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",
] }
...
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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 using fly 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)