DEV Community

Cover image for Authentication system using Golang and Sveltekit - Frontend Log in and out
John Owolabi Idogun
John Owolabi Idogun

Posted on

Authentication system using Golang and Sveltekit - Frontend Log in and out

Introduction

To see the code we wrote in the previous article in action, we'll implement complementary frontend pages in this article. Most of the code in this article is similar to what we did in registration and token validation.

Source code

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

GitHub logo Sirneij / go-auth

A fullstack session-based authentication system using golang and sveltekit

go-auth

This repository accompanies a series of tutorials on session-based authentication using Go at the backend and JavaScript (SvelteKit) on the front-end.

It is currently live here (the backend may be brought down soon).

To run locally, kindly follow the instructions in each subdirectory.




Implementation

Step 1: Login page

For the login route, we need to create a +page.svelte in routes/auth/login:

<!-- routes/auth/login/+page.svelte -->
<script>
    import { applyAction, enhance } from '$app/forms';
    import { page } from '$app/stores';
    import { receive, send } from '$lib/utils/helpers';

    /** @type {import('./$types').ActionData} */
    export let form;

    /** @type {import('./$types').SubmitFunction} */
    const handleLogin = async () => {
        return async ({ result }) => {
            await applyAction(result);
        };
    };

    let message = '';
    if ($page.url.searchParams.get('message')) {
        message = $page.url.search.split('=')[1].replaceAll('%20', ' ');
    }
</script>

<div class="container">
    <form class="content" method="POST" action="?/login" use:enhance={handleLogin}>
        <h1 class="step-title">Login User</h1>
        {#if form?.errors}
            {#each form?.errors as error (error.id)}
                <h4
                    class="step-subtitle warning"
                    in:receive={{ key: error.id }}
                    out:send={{ key: error.id }}
                >
                    {error.error}
                </h4>
            {/each}
        {/if}

        {#if message}
            <h4 class="step-subtitle">{message}</h4>
        {/if}

        <input type="hidden" name="next" value={$page.url.searchParams.get('next')} />
        <div class="input-box">
            <span class="label">Email:</span>
            <input class="input" type="email" name="email" placeholder="Email address" />
        </div>
        <div class="input-box">
            <span class="label">Password:</span>
            <input class="input" type="password" name="password" placeholder="Password" />
            <a href="/auth/password/request-change" style="margin-left: 1rem;">Forgot password?</a>
        </div>
        <div class="btn-container">
            <button class="button-dark">Login</button>
            <p>Have no account? <a href="/auth/register">Register here</a>.</p>
        </div>
    </form>
</div>
Enter fullscreen mode Exit fullscreen mode

As usual, this is just mostly HTML with Svelte's syntactic sugar. If you view this page in the browser, it looks just like this:

Application's login page

It also has a corresponding +page.server.js which handles the form submission:

// routes/auth/login/+page.server.js
import { BASE_API_URI } from '$lib/utils/constants';
import { formatError } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';

/** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) {
    // redirect user if logged in
    if (locals.user) {
        throw redirect(302, '/');
    }
}

/** @type {import('./$types').Actions} */
export const 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 data = await request.formData();
        const email = String(data.get('email'));
        const password = String(data.get('password'));
        const next = String(data.get('next'));

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

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

        if (!res.ok) {
            const response = await res.json();
            const errors = formatError(response.error);
            return fail(400, { errors: errors });
        }

        if (res.headers.has('Set-Cookie')) {
            const sessionID = Object.fromEntries(res.headers)
                ['set-cookie'].split(';')[0]
                .split(/=(.*)/s)[1];

            const path = Object.fromEntries(res.headers)['set-cookie'].split(';')[1].split('=')[1];
            const maxAge = Number(
                Object.fromEntries(res.headers)['set-cookie'].split(';')[2].split('=')[1]
            );

            cookies.set('go-auth-sessionid', sessionID, {
                httpOnly: true,
                sameSite: 'lax',
                path: path,
                secure: true,
                maxAge: maxAge
            });
        }

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

In the load() function, we bar authenticated users from accessing the page since they are already logged in. Then, the named form action. It's pretty similar to what we did before. The major difference is how we extracted the session token from the response header and set the cookie in the browser:

...
    if (res.headers.has('Set-Cookie')) {
        const sessionID = Object.fromEntries(res.headers)
            ['set-cookie'].split(';')[0]
            .split(/=(.*)/s)[1];

        const path = Object.fromEntries(res.headers)['set-cookie'].split(';')[1].split('=')[1];
        const maxAge = Number(
            Object.fromEntries(res.headers)['set-cookie'].split(';')[2].split('=')[1]
        );

        cookies.set('go-auth-sessionid', sessionID, {
            httpOnly: true,
            sameSite: 'lax',
            path: path,
            secure: true,
            maxAge: maxAge
        });
    }
...
Enter fullscreen mode Exit fullscreen mode

This saved cookie is then used in frontend/src/hooks.server.js to fetch the currently logged user:

// frontend/src/hooks.server.js
import { BASE_API_URI } from '$lib/utils/constants';

/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
    if (event.locals.user) {
        // if there is already a user  in session load page as normal
        return await resolve(event);
    }
    // get cookies from browser
    const session = event.cookies.get('go-auth-sessionid');

    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 = await res.json();

    event.locals.user = response;
    if (event.locals.user.profile.birth_date) {
        event.locals.user.profile.birth_date = response['profile']['birth_date'].split('T')[0];
    }

    // load page as normal
    return await resolve(event);
}
Enter fullscreen mode Exit fullscreen mode

handle hook is useful in achieving this thereby ensuring that our application's users remain logged in as long as their cookies are still valid. Immediately such cookies expire or get invalidated, users must re-login. A user whose cookie is valid gets access to data via the /users/current-user/ endpoint, one of the handlers implemented in the last article. Also, note that you need to send the cookie with the request for it to succeed.

Having retrieved the logged-in user, we need to make sure all other parts of our application have access it to. To do that, we will create a +layour.server.js and from there propagate the data:

// frontend/src/routes/+layout.server.js
/** @type {import('./$types').LayoutServerLoad} */
export async function load({ locals }) {
    return {
        user: locals.user
    };
}
Enter fullscreen mode Exit fullscreen mode

But since we already have +layout.js, the data from +layour.server.js will not be available unless we instruct +layout.js to propagate the data:

// frontend/src/routes/+layout.js

/** @type {import('./$types').LayoutLoad} */
export async function load({ fetch, url, data }) {
    const { user } = data;
    return { fetch, url: url.pathname, user };
}
Enter fullscreen mode Exit fullscreen mode

Now, every page can access the data via the data.user object of the page store. We will use it next.

Step 2: Logout logic

To log users out, we will also stick to web standards by using the form tag for such an action. Our frontend/src/lib/components/Header.svelte should now look like this:

<script>
    import { applyAction, enhance } from '$app/forms';
    import { page } from '$app/stores';
    import Developer from '$lib/img/hero-image.png';
    import Avatar from '$lib/img/teamavatar.png';
</script>

<header class="header">
    <div class="header-container">
        <div class="header-left">
            <div class="header-crafted-by-container">
                <a href="https://github.com/Sirneij">
                    <span>Developed by</span><img src={Developer} alt="John Owolabi Idogun" />
                </a>
            </div>
        </div>
        <div class="header-right">
            <div class="header-nav-item" class:active={$page.url.pathname === '/'}>
                <a href="/">home</a>
            </div>
            {#if !$page.data.user}
                <div class="header-nav-item" class:active={$page.url.pathname === '/auth/login'}>
                    <a href="/auth/login">login</a>
                </div>
                <div class="header-nav-item" class:active={$page.url.pathname === '/auth/register'}>
                    <a href="/auth/register">register</a>
                </div>
            {:else}
                <div class="header-nav-item">
                    <a href="/auth/about/{$page.data.user.id}">
                        <img
                            src={$page.data.user.thumbnail ? $page.data.user.thumbnail : Avatar}
                            alt={`${$page.data.user.first_name} ${$page.data.user.last_name}`}
                        />
                    </a>
                </div>
                <form
                    class="header-nav-item"
                    action="/auth/logout"
                    method="POST"
                    use:enhance={async () => {
                        return async ({ result }) => {
                            await applyAction(result);
                        };
                    }}
                >
                    <button type="submit">logout</button>
                </form>
            {/if}
        </div>
    </div>
</header>
Enter fullscreen mode Exit fullscreen mode

You can see that we accessed the user's data via $page.data.user. The $ at the start is called auto-subscription of stores.

In this part of the header:

...
    <form
        class="header-nav-item"
        action="/auth/logout"
        method="POST"
        use:enhance={async () => {
            return async ({ result }) => {
                await applyAction(result);
            };
        }}
    >
        <button type="submit">logout</button>
    </form>
...
Enter fullscreen mode Exit fullscreen mode

If you check the action, we are referencing a route, /auth/logout. The logout route. This route is non-existent yet. We will create it. However, this route will not have a +page.svelte file because we don't have what to show there. However, it will have a +page.server.js because that is what will send our data to the backend. Since we used the route as our action, then the form action will be unnamed or default. If the action were /auth/logout?/logout, then the form action wouldn't be the default:

// frontend/src/routes/auth/logout/+page.server.js

import { BASE_API_URI } from '$lib/utils/constants';
import { fail, redirect } from '@sveltejs/kit';

/** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) {
    // redirect user if not logged in
    if (!locals.user) {
        throw redirect(302, `/auth/login?next=/auth/logout`);
    }
}

/** @type {import('./$types').Actions} */
export const actions = {
    default: async ({ fetch, cookies }) => {
        /** @type {RequestInit} */
        const requestInitOptions = {
            method: 'POST',
            credentials: 'include',
            headers: {
                'Content-Type': 'application/json',
                Cookie: `sessionid=${cookies.get('go-auth-sessionid')}`
            }
        };

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

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

        // eat the cookie
        cookies.delete('go-auth-sessionid', { path: '/' });

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

Here, we only sent the cookie to the server. With that, the server knows who wants to log out. Then, it deletes such a cookie from the server and on a successful response, we also delete it in the browser.

With that, we will end it here. The next article will be about token regeneration and password reset functionalities. 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)