DEV Community

Cover image for Authentication system using rust (actix-web) and sveltekit - Enhancing the frontend application with form actions
John Owolabi Idogun
John Owolabi Idogun

Posted on • Updated on

Authentication system using rust (actix-web) and sveltekit - Enhancing the frontend application with form actions

Introduction

We have so far built a typical semi-SPA (Single-page application) whose post requests are done via JavaScript and without it, such requests will not pass the test of time. This has made us heavily rely on the Cross-Origin Resource Sharing (CORS) configuration previously written. Without it, our carefully built and performant authentication system will just be another waste of effort. If you have noticed, whenever you refresh the page, your session cookie is gone and you must re-login before private routes can be accessed. Why build an application that is not resilient? What happens to an app that is resilient and feels natural while not losing interactivity? Rich Harris called such apps Transitional applications. Resilient applications (work w/o JavaScript), conform with web accessibility rules and standards, less buggy, SEO friendly, very interactive (if JavaScript is available) and without bloated JavaScript files. That's what we'll turn our frontend application to in this article using the powers of SvelteKit Form Actions.

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: Remove CORS configuration and actix-cors from the backend

The first step we need to take in this challenging journey is to ensure that no front-end "JavaScript-based" application can access our backend service. To do this, we'll disable or more appropriately remove their enabler which, in this case, is actix-cors:

~/rust-auth/backend$ cargo remove actix-cors
Enter fullscreen mode Exit fullscreen mode

Then, remove the entire .wrap that houses its configuration from the run function in backend/src/startup.rs. We can now compile and run our application safely.

Step 2: Use form actions for the login route

If you notice, our frontend application has lost communication channels with our backend service. Hence, the front-end is broken. Let's start fixing it.

We'll start with our app's login route. Let's create a +page.server.ts in frontend/src/routes/auth/login/. In SvelteKit, only +page.server.ts not +page.ts files can export form actions and form actions, w/o JavaScript, can send POST data to the server. Also, since +page.server.ts only runs on the server, there's no need to configure CORS at the backend. This is because CORS mainly affect the browser. Our frontend/src/routes/auth/login/+page.server.ts should look like this:

// frontend/src/routes/auth/login/+page.server.ts

import { fail, type Actions, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { CustomError, LoginRequestBody } from '$lib/utils/types';
import { BASE_API_URI } from '$lib/utils/constant';

export const load: PageServerLoad = async ({ locals }) => {
    // redirect user if logged in
    if (locals.user) {
        throw redirect(302, '/');
    }
};

export const actions: Actions = {
    /**
     *
     * @param request - The request object
     * @param fetch - Fetch object from sveltekit
     * @param cookies - SvelteKit's cookie object
     * @returns Error data or redirects user to the home page or the previous page
     */
    login: async ({ request, fetch, cookies }) => {
        const formData = await request.formData();
        const email = String(formData.get('email'));
        const password = String(formData.get('password'));
        const next = String(formData.get('next'));

        const login: LoginRequestBody = {
            email,
            password
        };

        const apiURL = `${BASE_API_URI}/users/login/`;

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

        const res = await fetch(apiURL, 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 });
        }

        for (const header of res.headers) {
            if (header[0] === 'set-cookie') {
                cookies.set('id', header[1].split('=')[1].split(';')[0], {
                    httpOnly: true,
                    sameSite: 'lax',
                    path: '/',
                    secure: true
                });

                break;
            }
        }

        throw redirect(303, next || '/');
    }
};
Enter fullscreen mode Exit fullscreen mode

The file starts with some imports, followed by a load function whose only job, for now, is to prevent authenticated users from accessing the login page. Then, we exported a NAMED form action. We could have used the default but opted to be more explicit. This NAMED action, login, takes request, fetch, and cookies which were destructed from event — the single argument a form action takes.

In the login method, we retrieved the data from the form in frontend/src/routes/auth/login/+page.svelte. These data were deconstructed to build a JSON-compatible LoginRequestBody type since form actions only provide FormData. Notice a third field, next, from our form. This field stores the next page to be redirected to in case there was a page a user previously wanted to access but was redirected to the login page due to not being authenticated. Then, we formed and requested our backend service. If the response from the server was not as we expected, we used SvelteKit's fail to return a failure with a formed error response from the server. If otherwise, we searched through the response headers, retrieved its set-cookie and set the cookie. After that, we redirected the user to the "next" page or to the home page.

Next is frontend/src/routes/auth/login/+page.svelte:

<!-- frontend/src/routes/auth/login/+page.svelte -->
<script lang="ts">
    import { applyAction, enhance, type SubmitFunction } from '$app/forms';
    import { loading } from '$lib/stores/loading.store';
    import { notification } from '$lib/stores/notification.store';
    import { happyEmoji } from '$lib/utils/constant';
    import type { ActionData } from './$types';
    import { receive, send } from '$lib/utils/helpers/animate.crossfade';
    import { page } from '$app/stores';
    export let form: ActionData;
    const handleLogin: SubmitFunction = async () => {
        loading.setLoading(true, 'Please wait while we log you in...');
        return async ({ result }) => {
            loading.setLoading(false);
            if (result.type === 'success' || result.type === 'redirect') {
                $notification = {
                    message: `Login successful ${happyEmoji}...`,
                    colorName: `emerald`
                };
            }
            await applyAction(result);
        };
    };
</script>

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

<form class="form" method="POST" action="?/login" use:enhance={handleLogin}>
    <h1 style="text-align:center">Login</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="hidden" name="next" value={$page.url.searchParams.get('next')} />

    <input type="email" name="email" id="email" placeholder="Email address" required />

    <input type="password" name="password" id="password" placeholder="Password" required />

    <span style="display:block; text-align: right; margin-bottom: 0.5rem">
        <a href={null} class="text-sm text-slate-400"> Forgot password? </a>
    </span>

    <button type="submit" class="btn"> Login </button>

    <span class="text-sm text-sky-400" style="display:block; text-align: center; margin-top: 0.5rem">
        No account?
        <a href="/auth/register" class="ml-2 text-slate-400"> Create an account. </a>
    </span>
</form>
Enter fullscreen mode Exit fullscreen mode

This page has been reduced in length having shortened handleLogin and turned it into a SubmitFunction and removed the long tailwind CSS classes. The forms now use the styles in frontend/src/lib/css/styles.css. handleLogin is a custom function utilized by SvelteKit's use:enhance to progressively enhance our form so that if JavaScript is enabled or available, it will be as interactive as we want it. In the method, we enabled our loading animation while waiting for a response from our form action. If the form's return type, result type to be precise, is either a success or redirect, we notify the user using our notification store. Regardless, we used applyAction to update the entire application in case some of its data have changed. You can achieve this with update but customization will be lost.

Just before handleLogin, we exported form, an ActionData, that exposes whatever happens in our form actions. Using it, we displayed any errors from the form actions and any other data that we might have sent to the page. The form action and the use:enhance we talked about were used here:

...
<form class="form" method="POST" action="?/login" use:enhance={handleLogin}>
...
Enter fullscreen mode Exit fullscreen mode

A simple form element with a "POST" method (very important regardless of whether or not you are doing a "PUT", "PATCH" or "DELETE" request). You can use a "GET" but it's for a different scenario. Then, we set its action attribute to ?/login. login is the name of our form action. If we hadn't used a NAMED form action, we wouldn't have set the action attribute at all. Notice the difference. Next, we fed our handleLogin to use:enhance. This brings back our form interactivity. You don't need to always feed use:enhance with a function if you have no custom things to do to make your form interactive.

This hidden input element:

...
<input type="hidden" name="next" value={$page.url.searchParams.get('next')} />
...
Enter fullscreen mode Exit fullscreen mode

helps store the possible next page we'll redirect to after a successful login process.

This is it. We now have a resilient login form and the concepts learned here are all we need to make other features resilient!

Step 3: Persist user sessions regardless of any page refresh

As pointed out in the introduction, as soon as a user refreshes the page in our current application, such a user's session is destroyed and a new one is required. We need to change this to as long as the current user has a valid cookie and hasn't logged out, thereby destroying such a session, such a user should remain logged in regardless of (hard) page refresh. To achieve this, we will be using yet another feature of SvelteKit, called Hooks. Declaimer: it's different from React hooks. In SvelteKit, Hooks are app-wide and are used for a couple of things. However, we will use SvelteKit's handle hook to intercept every request the app receives and before fulfilling such requests, checks the browser whether or not there's a particular cookie. If there isn't, fulfil the request swiftly. If there is, however, retrieves the cookie and sends it to the backend which will in turn, if everything goes as planned and the cookie is valid, respond with a user who has such a cookie and such a user will be saved in the app's locals — the abode of custom request data. This entire modus operandi equates to the following snippet:

// frontend/src/hooks.server.ts
import { BASE_API_URI } from '$lib/utils/constant';
import type { User } from '$lib/utils/types';
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
    // get cookies from browser
    const session = event.cookies.get('id');

    if (!session) {
        // if there is no session load page as normal
        return await resolve(event);
    }

    // find the user based on the session
    const res = await event.fetch(`${BASE_API_URI}/users/current-user/`, {
        credentials: 'include',
        headers: {
            Cookie: `sessionid=${session}`
        }
    });

    if (!res.ok) {
        // if there is no session load page as normal
        return await resolve(event);
    }

    // if `user` exists set `events.local`
    const response: User = (await res.json()) as User;

    event.locals.user = response;

    // load page as normal
    return await resolve(event);
};

Enter fullscreen mode Exit fullscreen mode

To make this user data available to all other pages, we will propagate it with +layout.server.ts:

// frontend/src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';

// get `locals.user` and pass it to the `page` store
export const load: LayoutServerLoad = async ({ locals }) => {
    return {
        user: locals.user
    };
};
Enter fullscreen mode Exit fullscreen mode

Since we have a +layout.ts file already, the user data returned from its "server" counterpart, or more precisely, "parent" will NOT be made available unless we ask it to:

// frontend/src/routes/+layout.ts
import type { LayoutLoad } from './$types';

export const load: LayoutLoad = async ({ parent, data, fetch, url }) => {
    await parent();
    const { user } = data;
    return { fetch, url: url.pathname, user };
};
Enter fullscreen mode Exit fullscreen mode

We first awaited its parent, +layout.server.ts, and destructured user out of the data attribute which was then made available! Voila! Every page now has the user data via $page.data.user. Remember the page store right?

That's all we need to do to persist user data. Now, we can safely delete loggedInUser and isAuthenticated stores!!!

CAVEAT: Ensure the stored cookies have httpOnly and secure set to true for security reasons. You can go a step further by setting sameSite to either Lax or Strict. The latter was not tested by me yet.

Step 4: Update logout logic

Currently, in frontend/src/lib/component/Header/Header.svelte, we have a button with an on:click event attached to a logout function defined in frontend/src/lib/utils/requests/logout.requests.ts. In the spirit of web standards and to continue our resolve to enhance our application, we will replace that button with a form:

<!-- frontend/src/lib/component/Header/Header.svelte -->
...
<form action="/auth/logout" method="POST" use:enhance={handleLogout}>
    <button type="submit" class="text-white hover:text-sky-400 logout">Logout</button>
</form>
...
Enter fullscreen mode Exit fullscreen mode

The form has an action pointed to /auth/logout — a new route we'll create soon. It also has use:enhance attribute whose value is the handleLogout:

<!-- frontend/src/lib/component/Header/Header.svelte -->
<script lang="ts">
    ...
    const handleLogout: SubmitFunction = () => {
        loading.setLoading(true, 'Please wait while we log you out...');
        return async ({ result }) => {
            loading.setLoading(false);
            if (result.type === 'success' || result.type === 'redirect') {
                $notification = {
                    message: `Logout successfull ${happyEmoji}...`,
                    colorName: `emerald`
                };
            }
            await applyAction(result);
        };
    };
    ...
<script>
Enter fullscreen mode Exit fullscreen mode

Quite identical to handleLogin. Now, we need to create the logout route which that form action pointed to. In the route, create a +page.server.ts file:

// frontend/src/routes/auth/logout/+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';

export const load: PageServerLoad = async ({ locals }) => {
    // redirect user if not logged in
    if (!locals.user) {
        throw redirect(302, `/auth/login?next=/auth/logout`);
    }
};

export const actions: Actions = {
    default: async ({ fetch, cookies }) => {
        const requestInitOptions: RequestInit = {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Cookie: `sessionid=${cookies.get('id')}`
            }
        };

        const res = await fetch(`${BASE_API_URI}/users/logout/`, 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 });
        }

        // eat the cookie
        cookies.delete('id', { path: '/' });

        // redirect the user
        throw redirect(302, '/auth/login');
    }
};
Enter fullscreen mode Exit fullscreen mode

First, we ensured that only authenticated users could access the route using the load function. Then, we defined the DEFAULT form action which simply sends our cookie to the backend. If that's successful, we destroy — in literal terms, eat — such a cookie. Any request made afterwards with such a cookie will be denied! Default form action was used here because the action attribute's value on the form was the logout route. If we were to use a NAMED form action, the form's action attribute would be action="/auth/logout?/<namw_of_form_action>.

Now we can log out!!!

Step 4: Extend form actions to the register and about routes

There is nothing new with these two routes. The concepts required to build them have been learned. For completeness, I will put the contents of their respective +page.server.ts files here:

// frontend/src/routes/auth/register/+page.server.ts

import { redirect, type Actions, fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { BASE_API_URI } from '$lib/utils/constant';
import type { CustomError, RegisterRequestBody } from '$lib/utils/types';
import { isValidEmail, isValidPasswordMedium } from '$lib/utils/helpers/input.validation';
import { isEmpty } from '$lib/utils/helpers/test.object.empty';

export const load: PageServerLoad = async ({ locals }) => {
    // redirect user if logged in
    if (locals.user) {
        throw redirect(302, '/');
    }
};

export const actions: Actions = {
    /**
     *
     * @param request - The request object
     * @param fetch - Fetch object from sveltekit
     * @returns Error data or redirects user to the home page or the previous page
     */
    register: async ({ request, fetch }) => {
        const formData = await request.formData();
        const email = String(formData.get('email'));
        const firstName = String(formData.get('first_name'));
        const lastName = String(formData.get('last_name'));
        const password = String(formData.get('password'));
        const confirmPassword = String(formData.get('confirm_password'));

        // Some validations
        const fieldsError: Record<string, string> = {};
        if (!isValidEmail(email)) {
            fieldsError.email = 'That email address is invalid.';
        }
        if (!isValidPasswordMedium(password)) {
            fieldsError.password =
                'Password is not valid. Password must contain six characters or more and has at least one lowercase and one uppercase alphabetical character or has at least one lowercase and one numeric character or has at least one uppercase and one numeric character.';
        }
        if (confirmPassword.trim() !== password.trim()) {
            fieldsError.confirmPassword = 'Password and confirm password do not match.';
        }

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

        const registrationBody: RegisterRequestBody = {
            email,
            first_name: firstName,
            last_name: lastName,
            password
        };

        const apiURL = `${BASE_API_URI}/users/register/`;

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

        const res = await fetch(apiURL, 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();

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

And:

// frontend/src/routes/auth/about/[id]/+page.server.ts

import { redirect, type Actions, fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { BASE_API_URI, IMAGE_UPLOAD_SIZE } from '$lib/utils/constant';
import type { CustomError, User } from '$lib/utils/types';
import { returnFileSize } from '$lib/utils/helpers/image.file.size';

export const load: PageServerLoad = async ({ locals, params }) => {
    // redirect user if not logged in
    if (!locals.user) {
        throw redirect(302, `/auth/login?next=/auth/about/${params.id}`);
    }
};

export const actions: Actions = {
    /**
     *
     * @param request - The request object
     * @param fetch - Fetch object from sveltekit
     * @param cookies - SvelteKit's cookie object
     * @param locals - The local object, housing current user
     * @returns Error data or redirects user to the home page or the previous page
     */
    updateUser: async ({ request, fetch, cookies, locals }) => {
        const formData = await request.formData();

        // Some validations
        const errors: Array<CustomError> = [];

        // Ensure that the file is not too big
        if (formData.get('thumbnail')) {
            const file = formData.get('thumbnail') as File;
            if (file.size <= 0) {
                formData.delete('thumbnail');
            } else {
                const [size, isValid] = returnFileSize(file.size);

                if (!isValid) {
                    errors.push({
                        id: Math.floor(Math.random() * 100),
                        error: `Image size ${size} is too large. Please keep it below ${IMAGE_UPLOAD_SIZE}kB.`
                    });
                    formData.delete('thumbnail');
                }
            }
        }
        // Ensure that first_name is different from the current one
        if (formData.get('first_name')) {
            const firstName = formData.get('first_name');
            if (firstName === locals.user.first_name || firstName === '') {
                formData.delete('first_name');
            }
        }
        // Ensure that last_name is different from the current one
        if (formData.get('last_name')) {
            const lastName = formData.get('last_name');
            if (lastName === locals.user.last_name || lastName === '') {
                formData.delete('last_name');
            }
        }

        if (errors.length > 0) {
            return fail(400, { errors: errors });
        }

        const apiURL = `${BASE_API_URI}/users/update-user/`;

        const res = await fetch(apiURL, {
            method: 'PATCH',
            headers: {
                Cookie: `sessionid=${cookies.get('id')}`
            },
            body: formData
        });

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

        const response = (await res.json()) as User;

        locals.user = response;

        throw redirect(303, `/auth/about/${response.id}`);
    }
};
Enter fullscreen mode Exit fullscreen mode

Just the regular form validations and, upon successful requests to the server, accompanying redirections.

NOTE: In the main repository, we made changes to update_user.rs and some utils in the backend app. We also updated some of the routes frontend to reflect the overall changes we made. Kindly compare your version of the code with that in the main branch.

With these, we have made a truly transitional application that is resilient, performant, SEO friendly, obeys web accessibility rules, and conforms to web standards, among others.

I welcome gigs, comments, criticisms (constructive), collaborations and all other nifty stuff... See you in the next article where we'll implement activation token regeneration and some changing users' passwords.

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)