Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to implement cookie authentication | SvelteKit & MongoDB

The question stands as-is - how to implement cookie authentication in a SvelteKit & MongoDB app? Meaning how to properly use hooks, endpoints, establish a DB connection and show it on a boilerplate-ish project.

like image 625
Nikolas Blahušiak Avatar asked Sep 05 '21 18:09

Nikolas Blahušiak


People also ask

How do you implement cookie-based authentication?

The entire cookie-based authentication works in the following manner: The user gives a username and password at the time of login. Once the user fills in the login form, the browser (client) sends a login request to the server. The server verifies the user by querying the user data.

Which authentication uses cookies for user authentication?

Cookie authentication uses HTTP cookies to authenticate client requests and maintain session information. It works as follows: The client sends a login request to the server.

How secure is cookie-based authentication?

By default, Cookie-based authentication does not have solid protection against attacks, and they are mainly vulnerable to cross-site scripting (XSS) and cross-site request forgery (CSRF)attacks. But, we can explicitly modify Cookie headers to make them protected against such attacks.


1 Answers

After SvelteKit Project Initialisation


#1 Install additional dependencies

npm install config cookie uuid string-hash mongodb
  • I prefer config over vite's .env variables due to all the leaks and problems regarding it
  • cookie is used to properly set cookies
  • uuid is used to generate complex cookie IDs
  • string-hash is a simple yet secure hashing for passwords stored in your DB
  • mongodb is used to establish a connection to your DB

#2 Set up config

In root, create a folder called config. Inside it, create a file called default.json.

config/default.json

{
    "mongoURI": "<yourMongoURI>",
    "mongoDB": "<yourDatabaseName>"
}

#3 Set up base DB connection code

Create lib folder in src. Inside it, create db.js file.

src/lib/db.js

import { MongoClient } from 'mongodb';
import config from 'config';

export const MONGODB_URI = config.get('mongoURI');
export const MONGODB_DB = config.get('mongoDB');

if (!MONGODB_URI) {
    throw new Error('Please define the mongoURI property inside config/default.json');
}

if (!MONGODB_DB) {
    throw new Error('Please define the mongoDB property inside config/default.json');
}

/**
 * Global is used here to maintain a cached connection across hot reloads
 * in development. This prevents connections growing exponentially
 * during API Route usage.
 */
let cached = global.mongo;

if (!cached) {
    cached = global.mongo = { conn: null, promise: null };
}

export const connectToDatabase = async () => {
    if (cached.conn) {
        return cached.conn;
    }

    if (!cached.promise) {
        const opts = {
            useNewUrlParser: true,
            useUnifiedTopology: true
        };

        cached.promise = MongoClient.connect(MONGODB_URI, opts).then((client) => {
            return {
                client,
                db: client.db(MONGODB_DB)
            };
        });
    }
    cached.conn = await cached.promise;
    return cached.conn;
}

The code is taken from next.js implementation of MongoDB connection establishment and modified to use config instead of .env.

#4 Create hooks.js file inside src

src/hooks.js

import * as cookie from 'cookie';
import { connectToDatabase } from '$lib/db';

// Sets context in endpoints
// Try console logging context in your endpoints' HTTP methods to understand the structure
export const handle = async ({ request, resolve }) => {
    // Connecting to DB
    // All database code can only run inside async functions as it uses await
    const dbConnection = await connectToDatabase();
    const db = dbConnection.db;

    // Getting cookies from request headers - all requests have cookies on them
    const cookies = cookie.parse(request.headers.cookie || '');
    request.locals.user = cookies;

    // If there are no cookies, the user is not authenticated
    if (!cookies.session_id) {
        request.locals.user.authenticated = false;
    }

    // Searching DB for the user with the right cookie
    // All database code can only run inside async functions as it uses await
    const userSession = await db.collection('cookies').findOne({ cookieId: cookies.session_id });

    // If there is that user, authenticate him and pass his email to context
    if (userSession) {
        request.locals.user.authenticated = true;
        request.locals.user.email = userSession.email;
    } else {
        request.locals.user.authenticated = false;
    }

    const response = await resolve(request);

    return {
        ...response,
        headers: {
            ...response.headers
            // You can add custom headers here
            // 'x-custom-header': 'potato'
        }
    };
};

// Sets session on client-side
// try console logging session in routes' load({ session }) functions
export const getSession = async (request) => {
    // Pass cookie with authenticated & email properties to session
    return request.locals.user
        ? {
                user: {
                    authenticated: true,
                    email: request.locals.user.email
                }
          }
        : {};
};

Hooks authenticate the user based on cookies and pass the desired variables (in this example it is the user's email etc.) to context & session.

#5 Create register.js & login.js Endpoints inside auth folder

src/routes/auth/register.js

import stringHash from 'string-hash';
import * as cookie from 'cookie';
import { v4 as uuidv4 } from 'uuid';
import { connectToDatabase } from '$lib/db';

export const post = async ({ body }) => {
    // Connecting to DB
    // All database code can only run inside async functions as it uses await
    const dbConnection = await connectToDatabase();
    const db = dbConnection.db;

    // Is there a user with such an email?
    const user = await db.collection('testUsers').findOne({ email: body.email });

    // If there is, either send status 409 Conflict and inform the user that their email is already taken
    // or send status 202 or 204 and tell them to double-check on their credentials and try again - it is considered more secure
    if (user) {
        return {
            status: 409,
            body: {
                message: 'User with that email already exists'
            }
        };
    }

    // Add user to DB
    // All database code can only run inside async functions as it uses await
    await db.collection('testUsers').insertOne({
        name: body.name,
        email: body.email,
        password: stringHash(body.password)
    });

    // Add cookie with user's email to DB
    // All database code can only run inside async functions as it uses await
    const cookieId = uuidv4();
    await db.collection('cookies').insertOne({
        cookieId,
        email: body.email
    });

    // Set cookie
    // If you want cookies to be passed alongside user when they redirect to another website using a link, change sameSite to 'lax'
    // If you don't want cookies to be valid everywhere in your app, modify the path property accordingly
    const headers = {
        'Set-Cookie': cookie.serialize('session_id', cookieId, {
            httpOnly: true,
            maxAge: 60 * 60 * 24 * 7,
            sameSite: 'strict',
            path: '/'
        })
    };

    return {
        status: 200,
        headers,
        body: {
            message: 'Success'
        }
    };
};

If you want to take it a step further, don't forget to create Schemas with Mongoose!

src/routes/auth/login.js

import stringHash from 'string-hash';
import * as cookie from 'cookie';
import { v4 as uuidv4 } from 'uuid';
import { connectToDatabase } from '$lib/db';

export const post = async ({ body }) => {
    const dbConnection = await connectToDatabase();
    const db = dbConnection.db;

    const user = await db.collection('testUsers').findOne({ email: body.email });

    if (!user) {
        return {
            status: 401,
            body: {
                message: 'Incorrect email or password'
            }
        };
    }

    if (user.password !== stringHash(body.password)) {
        return {
            status: 401,
            body: {
                message: 'Unauthorized'
            }
        };
    }

    const cookieId = uuidv4();

    // Look for existing email to avoid duplicate entries
    const duplicateUser = await db.collection('cookies').findOne({ email: body.email });

    // If there is user with cookie, update the cookie, otherwise create a new DB entry
    if (duplicateUser) {
        await db.collection('cookies').updateOne({ email: body.email }, { $set: { cookieId } });
    } else {
        await db.collection('cookies').insertOne({
            cookieId,
            email: body.email
        });
    }

    // Set cookie
    const headers = {
        'Set-Cookie': cookie.serialize('session_id', cookieId, {
            httpOnly: true,
            maxAge: 60 * 60 * 24 * 7,
            sameSite: 'strict',
            path: '/'
        })
    };

    return {
        status: 200,
        headers,
        body: {
            message: 'Success'
        }
    };
};

#6 Create Register.svelte and Login.svelte components

src/lib/Register.svelte

<script>
    import { createEventDispatcher } from 'svelte';

    // Dispatcher for future usage in /index.svelte
    const dispatch = createEventDispatcher();

    // Variables bound to respective inputs via bind:value
    let email;
    let password;
    let name;
    let error;

    const register = async () => {
        // Reset error from previous failed attempts
        error = undefined;

        try {
            // POST method to src/routes/auth/register.js endpoint
            const res = await fetch('/auth/register', {
                method: 'POST',
                body: JSON.stringify({
                    email,
                    password,
                    name
                }),
                headers: {
                    'Content-Type': 'application/json'
                }
            });

            if (res.ok) {
                dispatch('success');
            } else {
                error = 'An error occured';
            }
        } catch (err) {
            console.log(err);
            error = 'An error occured';
        }
    };
</script>

<h1>Register</h1>
<input type="text" name="name" placeholder="Enter your name" bind:value={name} />
<input type="email" name="email" placeholder="Enter your email" bind:value={email} />
<input type="password" name="password" placeholder="Enter your password" bind:value={password} />
{#if error}
    <p>{error}</p>
{/if}
<button on:click={register}>Register</button>

src/lib/Login.svelte

<script>
    import { createEventDispatcher } from 'svelte';

    // Dispatcher for future usage in /index.svelte
    const dispatch = createEventDispatcher();

    // Variables bound to respective inputs via bind:value
    let email;
    let password;
    let error;

    const login = async () => {
        // Reset error from previous failed attempts
        error = undefined;

        // POST method to src/routes/auth/login.js endpoint
        try {
            const res = await fetch('/auth/login', {
                method: 'POST',
                body: JSON.stringify({
                    email,
                    password
                }),
                headers: {
                    'Content-Type': 'application/json'
                }
            });

            if (res.ok) {
                dispatch('success');
            } else {
                error = 'An error occured';
            }
        } catch (err) {
            console.log(err);
            error = 'An error occured';
        }
    };
</script>

<h1>Login</h1>
<input type="email" name="email" placeholder="Enter your email" bind:value={email} />
<input type="password" name="password" placeholder="Enter your password" bind:value={password} />
{#if error}
    <p>{error}</p>
{/if}
<button on:click={login}>Login</button>

#7 Update src/routes/index.svelte

src/routes/index.svelte

<script>
    import Login from '$lib/Login.svelte';
    import Register from '$lib/Register.svelte';
    import { goto } from '$app/navigation';

    // Redirection to /profile
    function redirectToProfile() {
        goto('/profile');
    }
</script>

<main>
    <h1>Auth with cookies</h1>

    <!-- on:success listens for dispatched 'success' events -->
    <Login on:success={redirectToProfile} />
    <Register on:success={redirectToProfile} />
</main>

#8 Create index.svelte inside profile folder

src/routes/profile/index.svelte

<script context="module">
    export async function load({ session }) {
        if (!session.user.authenticated) {
            return {
                status: 302,
                redirect: '/auth/unauthorized'
            };
        }

        return {
            props: {
                email: session.user.email
            }
        };
    }
</script>

<script>
    import { onMount } from 'svelte';

    export let email;
    let name;

    onMount(async () => {
        const res = await fetch('/user');
        const user = await res.json();
        name = user.name;
    });
</script>

<h1>Profile</h1>
<p>Hello {name} you are logged in with the email {email}</p>

Pay attention to session we set up in hooks.js. console.log() it to understand its structure better. I won't be implementing /auth/unauthorized route, so mind that.

#9 Create index.js endpoint inside user folder

src/routes/user/index.js

import { connectToDatabase } from '$lib/db';

export const get = async (context) => {
    // Connecting to DB
    // All database code can only run inside async functions as it uses await
    const dbConnection = await connectToDatabase();
    const db = dbConnection.db;

    // Checking for auth coming from hooks' handle({ request, resolve })
    if (!context.locals.user.authenticated) {
        return {
            status: 401,
            body: {
                message: 'Unauthorized'
            }
        };
    }

    const user = await db.collection('testUsers').findOne({ email: context.locals.user.email });

    if (!user) {
        return {
            status: 404,
            body: {
                message: 'User not found'
            }
        };
    }

    // Find a proper way in findOne(), I've run out of gas ;)
    delete user.password;

    return {
        status: 200,
        body: user
    };
};

Final thoughts

There are almost none tutorials regarding SvelteKit and I'll surely find this guide useful in my future projects. If you find a bug or see an improvement, feel free to let me know so I can make this guide better ;)

Big thanks to Brayden Girard for a precedent for this guide!

https://www.youtube.com/channel/UCGl66MHcjMDJyIPZkuKULSQ

Happy coding!

like image 138
Nikolas Blahušiak Avatar answered Sep 21 '22 03:09

Nikolas Blahušiak