Nuxt/docs/2.guide/4.recipes/4.sessions-and-authentication.md
2024-06-16 13:15:39 -04:00

15 KiB

title description
Sessions and Authentication User registration and authentication is an extremely common requirement in web apps. This recipe will show you how to implement basic user registration and authentication in you Nuxt app.

Introduction

In this recipe we'll be setting up user registration, login, sessions, and authentication in a full-stack Nuxt app. We'll be using Nuxt Auth Utils by Atinux (Sébastien Chopin) which provides convenient utilities for managing client-side and server-side session data. We'll install and use this to get the core session management functionality we're going to need to manage user logins. For the database ORM we'll be using Drizzle with db0, but you can use any ORM or database connection strategy you prefer.

You'll need a users table in your database with the following columns:

  • id (int, primary key, auto increment)
  • email (varchar)
  • password (varchar)

Steps

1. Install nuxt-auth-utils

Install the nuxt-auth-utils module using the nuxi CLI.

npx nuxi@latest module add auth-utils

1a. (Optional) Add a session encryption key

Session cookies are encrypted using a key set from the .env file. This key will be added to your .env automatically when running in development mode the first time. However, you'll need to add this to your production environment before deploying.

NUXT_SESSION_PASSWORD=password-with-at-least-32-characters

2. Create a registration page

The first page we'll need is a page for users to register and create new accounts. Create a new Vue page in your Nuxt app at /pages/register.vue for user registration. This page should have a form with fields for email and password. We'll intercept the form submission using @submit.prevent and use the $fetch utility to post the data to /api/register. This form POST will be received by Nuxt in an API route which we will set up next.

If the request is successful, we'll navigate to the (soon to be created) /users page, which will be guarded and only visible to logged in users.

Here's an example registration form for reference:

<script setup lang="ts">
  const form = ref({
    name: "",
    email: "",
    password: "",
  });

  const error = ref(null);

  async function submitForm() {
    // clear any previous errors
    error.value = null;

    // perform the login
    try {
      await $fetch("/api/register", {
        method: "POST",
        body: form.value,
      });
    } catch (e) {
      // if there's an error, set the error message and return early
      error.value = "Error Registering User";
      return;
    }
    // Refresh the session status now that the user is logged in
    // This is a fetch() function from auth-utils to refresh session data in the client, and is not the same as $fetch
    const { fetch } = useUserSession();
    await fetch();
    // you may want to use something like Pinia to manage global state of the logged-in user
    // update Pinia state here...

    // take the user to the auth-only users index page now that they're logged in
    await navigateTo("/users");

    // Alternative - Don't use Nuxt Router here so that we can easily trigger a whole page load and get the whole UI refreshed now that the user is logged in.
    // window.location.href = "/users";
  }
</script>

<template>
  <div>
    <form class="space-y-2" @submit.prevent="submitForm">
      <div>
        <label for="name" class="block text-sm uppercase">Name</label>

        <input id="name" name="name" v-model="form.name" class="rounded-md px-2 py-1" required />
      </div>

      <div>
        <label for="email" class="block text-sm uppercase">Email</label>

        <input id="email" name="email" v-model="form.email" class="rounded-md px-2 py-1" required />
      </div>

      <div>
        <label for="password" class="block text-sm uppercase">password</label>

        <input
            id="password"
            name="password"
            v-model="form.password"
            class="rounded-md px-2 py-1"
            type="password"
            required
        />
      </div>

      <div class="pt-2">
        <button type="submit">Register</button>
      </div>

      <div v-if="error">{{ error }}</div>
    </form>
  </div>
</template>

3. Create an API route for registration

With the user interface created we'll need to add a route to receive the registration form data. This route should accept a POST request with the email and password in the request body. It should hash the password and insert the user into the database. This route will only accept POST requests, so we'll follow the instructions for API route methods and name the file with *.post.ts to restrict the endpoint to only accept POST.

After we've successfully registered the user and stored the record in the database we can log them in by calling the replaceUserSession utility function from auth-utils. This utility function is automatically imported by the auth-utils module. We're using replaceUserSession here to make sure that any existing session data is cleared and replaced with the user login we're performing now.

The example file below is a very simple example of registration. You would probably want to add some error handling and nice response messages.

import users from "~/database/schema/users";
import getDatabase from "~/database/database";
import bcrypt from "bcrypt";

export default defineEventHandler(async (event) => {
  const body = await readBody(event);

  const db = await getDatabase();

  // has the password before creating the user record
  const passwordHash = bcrypt.hashSync(body.password, 12);
  await db.insert(users).values({
    name: body.name,
    email: body.email,
    password: passwordHash,
  });

  // get the user record we just created
  const user = (await db.select().from(users).where(eq(users.email, body.email)).limit(1))[0];
  // log the user in as the user that was just created
  await replaceUserSession(event, {
    user: {
      id: user.id,
      name: user.name,
    },
    loggedInAt: new Date(),
  });
  
  
  await auth.login(event, user);
});

4. Create a login page

When registered users return to your site they'll need to be able to log back in. Create a new page at /pages/login.vue in your Nuxt app for user login. This page should have a form with fields for email and password and should submit a POST request to /api/login.

Like the registration page, we'll intercept the form submission using @submit.prevent and use $fetch to post the data to /api/login.

This is a very simple login form example, so you'd definitely want to add more validation and error checking in a real-world application.

<script setup lang="ts">
  import { ref } from "vue";

  const form = ref({
    email: "",
    password: "",
  });

  const error = ref(null);

  async function submitForm() {
    // clear any previous errors
    error.value = null;

    // perform the login
    try {
      await $fetch("/api/auth/login", {
        method: "POST",
        body: loginForm.value,
      });
    } catch (e) {
      // if there's an error, set the error message and return early
      error.value = "Error logging in";
      return;
    }

    // Refresh the session status now that the user is logged in
    // This is a fetch() function from auth-utils to refresh session data in the client, and is not the same as $fetch
    const { fetch } = useUserSession();
    await fetch();
    
    // you may want to use something like Pinia to manage global state of the logged-in user
    // update Pinia state here...

    // take the user to the auth-only users index page now that they're logged in
    await navigateTo("/users");

    // Alternative - Don't use Nuxt Router here so that we can easily trigger a whole page load and get the whole UI refreshed now that the user is logged in. This will perform a full-page load.
    // window.location.href = "/users";
  }
</script>

<template>
  <div>
    <form class="space-y-2" @submit.prevent="submitForm">
      <div>
        <label for="email" class="block text-sm uppercase">Email</label>

        <input id="email" name="email" v-model="form.email" class="rounded-md px-2 py-1" required />
      </div>

      <div>
        <label for="password" class="block text-sm uppercase">password</label>

        <input
            id="password"
            name="password"
            v-model="form.password"
            class="rounded-md px-2 py-1"
            type="password"
            required
        />
      </div>

      <div class="pt-2">
        <button type="submit">Login</button>
      </div>

      <div v-if="error">{{ error }}</div>
    </form>
  </div>
</template>

5. Create an API route for login

With the login form created, we need to create an API route to handle the login request. This route should accept a POST request with the email and password in the request body and check the email and password against the database. If the user and password match, we'll set a session cookie to log the user in.

This server API route should be at /server/api/auth/login.post.ts. Just like the registration form endpoint, suffixing the filename in .post.ts. means that this handler will only respond to post requests.

import users from "~/database/schema/users";
import getDatabase from "~/database/database";
import { eq } from "drizzle-orm";
import bcrypt from "bcrypt";

export default defineEventHandler(async (event) => {
  const db = await getDatabase();

  const foundUser = (
    await db
      .select({ id: users.id, name: users.name, email: users.email, password: users.password })
      .from(users)
      .where(eq(users.email, email))
      .limit(1)
  )?.[0];

  // compare the password hash
  if (!foundUser || !bcrypt.compareSync(password, foundUser.password)) {
    // return an error if the user is not found or the password doesn't match
    throw createError({
      statusCode: 401,
      statusMessage: "Invalid email or password",
    });
  }

  // log in as the selected user
  await replaceUserSession(event, {
    user: {
      id: user.id,
      name: user.name,
    },
    loggedInAt: new Date(),
  });
});


The user should now be logged in! With the session set, we can get the current user session in any API route or page by calling getUserSession(event) which is auto-imported as a util function from the nuxt-auth-utils package.

6. Create a logout API route

Users need to be able to log out, so we should create an API route to allow them to do this. This should require post request as well, just like login. We'll clear the session when this endpoint is called.

We'll use the clearUserSession from the auth-utils module to log the user out and clear the session data. This function is automatically imported from the module by Nuxt, so we don't need to manually import it.

export default defineEventHandler(async (event) => {
  // Clear the current user session
  await clearUserSession(event);
});

7. Protect your server route

Protecting server routes is key to making sure your data are safe. Client-side middleware is helpful for the user, but without server-side protection your data can still be accessed. Because of this, it is critical that we protect any API routes with sensitive data. For these sensitive routes, we should return a 401 error if the user is not logged in.

The auth-utils module provides the requireUserSession utility function to help make sure that users are logged in and have an active session. We can use this to protect our different endpoints. Like many of the other utilities from the auth module, it is automatically imported in our server endpoints.

In the example below, we use the requireUserSession utility function to protect the /server/api/users.get.ts server route. This route will only be accessible to logged-in users.

import getDatabase from "~/database/database";
import users from "~/database/schema/users";
import requireUserLoggedIn from "~/server/utils/requireUserLoggedIn";

export default defineEventHandler(async (event) => {
  // make sure the user is logged in
  // This will throw a 401 error if the request doesn't come from a valid user session
  await requireUserSession(event);

  // If we make it here, the user is authenticated. It's safe to fetch and return data
  const db = await getDatabase();
  
  // Send back the list of users
  const userList = await db.select({ name: users.name, id: users.id }).from(users).limit(10);

  return userList;
});

8. Create a client-side middleware to protect routes

Our data are safe with the server-side route in place, but without doing anything else, unauthenticated users would probably get some odd data when trying to access the /users page. We should create a client-side middleware to protect the route on the client side and redirect users to the login page.

nuxt-auth-utils provides a convenient useUserSession composable which we'll use to check if the user is logged in, and redirect them if they are not.

We'll create a middleware in the /middleware directory. Unlike on the server, client-side middleware is not automatically applied to all endpoints, and we'll need to specify where we want it applied.

export default defineNuxtRouteMiddleware(() => {
    // check if the user is logged in
    const { loggedIn } = useUserSession();

    // redirect the user to the login screen if they're not authenticated
    if (!loggedIn.value) {
        return navigateTo("/login");
    }

    return null;
});

9. Protect a route with the client-side middleware

Now that we have the client-side middleware to protect client-side routes, we can use it in any page to ensure that only logged-in users can access the route. Users will be redirected to the login page if they are not authenticated.

We'll use definePageMeta to apply the middleware to the route that we want to protect.

::important ⚠️ Remember that your data aren't really secure without server-side protection! Always secure your data server-side first before worrying about the client-side. ::

<script setup lang="ts">
  
definePageMeta({
  middleware: ["redirect-if-not-authenticated"],
});
  
const { data: users } = await useFetch("/api/users");


</script>

<template>
  <div>
    <div class="pb-4 text-sm font-semibold uppercase">Users List</div>

    <div>
      <ul class="space-y-4">
        <li v-for="user in users" :key="user.id">
          <NuxtLink :to="`/users/${user.id}`">
            <div class="px-4 py-2 hover:bg-gray-600">{{ user.name }}</div>
          </NuxtLink>
        </li>
      </ul>
    </div>
  </div>
</template>

Complete

We've successfully set up user registration and authentication in our Nuxt app. Users can now register, log in, and log out. We've also protected sensitive routes on the server and client side to ensure that only authenticated users can access them.