How to create a safe login for a client's dashboard (using Nuxt-kql & headless kirby)

Hello!

I’m creating a headless kirby-setup. I combined kirby-headless-starter and nuxt-kql as a starting point. Everything works like expected :slight_smile:

In addition to this I would like to create a custom dashboard for my client’s where they can login using their Kirby credentials in the frontend. The dashboard should then populate with data connected to this client.

Reading the docs with my somewhat minimal level of knowledge I seem to be unable to do a post request to the http://localhost:8000/auth/login (Kirby url) endpoint coming from http://localhost:300/ which is the Nuxt frontend. This, due to CORS issues. Am I doing this the wrong way? do I need to configure it some other way? If I use Insomnia to receive the data from the endpoint api/auth/ I don’t get an error.

The whole setup should be bit more complicated, but I’ll already get at a distance finding a solution for this bridge :slight_smile:

Some code to give some context :slight_smile:

I created a Nuxt store using Pinia to store the session token (csfr):

/store/auth.ts :

async authenticateUser({ username, password }: UserPayloadInterface) {
      // useFetch from nuxt 3
      const credentials = username + ":" + password;
      const csrf = "<?= csrf() ?>";

      console.log(credentials);
      console.log(btoa(credentials));
      const { data, pending }: any = await useFetch("http://localhost:8000/api/auth/login", {
          method: "post",
          headers: new Headers({ "Content-Type": "application/json", "X-CSRF": csrf, Authorization: "Basic " + btoa(credentials) }),
          body: {
              username,
              password,
          },
      });
      console.log("de response: ", data);
      this.loading = pending;

      if (data.value) {
          const token = useCookie("token"); // useCookie new hook in nuxt 3
          token.value = data?.value?.token; // set token to cookie
          this.authenticated = true; // set authenticated  state value to true
      }
  }

/middleware/auth.ts :

import { storeToRefs } from "pinia";
import { useAuthStore } from "~/store/auth";

export default defineNuxtRouteMiddleware((to) => {
    const { authenticated } = storeToRefs(useAuthStore()); // make authenticated state reactive
    const token = useCookie("token"); // get token from cookies

    if (token.value) {
        // check if value exists
        authenticated.value = true; // update the state to authenticated
    }

    // if token exists and url is /login redirect to homepage
    if (token.value && to?.name === "login") {
        return navigateTo("/");
    }

    // if token doesn't exist redirect to log in
    if (!token.value && to?.name !== "login") {
        abortNavigation();
        return navigateTo("/login");
    }
});

The username and password is gathered from a form.

In the headless Kirby instance I’ve added the following header as well:
header('Access-Control-Allow-Origin: http://localhost:3000');

The config for the api looks like this (alongside the kql auth):

'api' => [
        'basicAuth' => true,
        'allowInsecure' => true,
],
'kql' => [
        'auth' => 'bearer'
],

When the fired I get the following error in my console:
POST http://localhost:8000/api/auth/login 500 (Internal Server Error)

:thought_balloon: In the meantime I’ve figured out a way to make it work. Will post the solution here soon when I’ve refactored it.

I’m aware that this might be basic knowledge for some forum-users. But still, I would like to share my approach for those who bump into the same question. And also to hear from the advanced users if this is a safe approach(?) (Not quit sure if it’s even worthly enough to reach the infamous Cookbook…)

First off you need to install both Nuxt-kql & kirby-headless-starter.

I didn’t have to tweak the headless Kirby Instance. The authentication is done by doing a post-request from Nuxt (localhost:3000) to Kirby’s (localhost:8000) enpoint api/auth/login.

For this I installed Nuxt.js | Pinia to act as a store to handle the received response from the post-request.

/store/auth.ts :

import { defineStore } from "pinia";

interface UserPayloadInterface {
    username: string;
    password: string;
}

export const useAuthStore = defineStore("auth", {
    state: () => ({
        authenticated: false,
        loading: false,
    }),
    actions: {
        authenticateUser({ username, password }: UserPayloadInterface) {
            const loginApi = "http://localhost:8000/api/auth/login";
            const csrf = "<?= csrf() ?>";
            const credentials = username + ":" + password;

            // authenticate user with kirby headless api
            async function authenticate() {
                const response = await fetch(loginApi, {
                    method: "post",
                    // for reference: "/vendor/getkirby/cms/config/api/routes/auth.php" && https://getkirby.com/docs/reference/objects/cms/auth/login
                    headers: new Headers({ "Content-Type": "application/json", "X-CSRF": csrf, Authorization: "Basic " + btoa(credentials) }),
                    body: JSON.stringify({ email: username, password: password, long: false }),
                });
                if (!response.ok) {
                    const message = `An error has occured: ${response.status}`;
                    throw new Error(message);
                }
                const data = await response.json();
                return data;
            }
            // then set token cookie
          authenticate().then((data) => {
              const token = useCookie("token"); // useCookie new hook in nuxt 3
              token.value = data.user.id; // set token to cookie
              if (!data.ok) {
                  useRouter().push("/dashboard"); // redirect to dashboard page
                  this.authenticated = true; // set authenticated state value to true
              }
          });
        },
        logUserOut() {
            const token = useCookie("token"); // useCookie new hook in nuxt 3
            this.authenticated = false; // set authenticated  state value to false
            token.value = null; // clear the token cookie
            useRouter().push("/login"); // redirect to login page
        },
    },
});

/middleware/auth.ts :

import { storeToRefs } from "pinia";
import { useAuthStore } from "~/store/auth";

export default defineNuxtRouteMiddleware((to) => {
    const { authenticated } = storeToRefs(useAuthStore()); // make authenticated state reactive
    const token = useCookie("token"); // get token from cookies

    if (token.value) {
        // check if value exists
        authenticated.value = true; // update the state to authenticated
    }

    // if token exists and url is /login redirect to dashboard
    if (token.value && to?.name === "login") {
        return navigateTo("/dashboard");
    }

    // if token doesn't exist redirect to log in
    if (!token.value && to?.name !== "login") {
        abortNavigation();
        return navigateTo("/login");
    }
});

pages/login.vue :

<script lang="ts" setup>
import { storeToRefs } from "pinia"; // import storeToRefs helper hook from pinia
import { useAuthStore } from "~/store/auth"; // import the auth store we just created

const { authenticateUser } = useAuthStore(); // use authenticateUser action from  auth store
const { authenticated } = storeToRefs(useAuthStore()); // make authenticated state reactive with storeToRefs

const user = ref({
    username: "",
    password: "",
});
const router = useRouter();

// bind the login function to the login button  click event and prevent default form submission
const login = async () => {
    await authenticateUser(user.value); // call authenticateUser and pass the user object
    // redirect to homepage if user is authenticated
    if (authenticated) {
        router.push("/dashboard");
    }
};
definePageMeta({
    middleware: "auth", // this should match the name of the file inside the middleware directory
});
</script>

pages/dashboard.vue :

<script setup lang="ts">
import { useAuthStore } from "~/store/auth"; // import the auth store we just created
const { logUserOut } = useAuthStore(); // use logUserOut action from  auth store
const token = useCookie("token"); // useCookie new hook in nuxt 3

definePageMeta({
    middleware: "auth", // this should match the name of the file inside the middleware directory
});
</script>

This middleware /middleware/auth.ts I’ve added to my dashboard-page and login-page. It automatically routes the user to the login-page if it can’t find the token in the Pinia store. If the token exists and the url is /login
it will redirect to /dashboard.

The /store/auth.ts handles the authentication post request. It has an action to login and logout.
The login action does the fetching and uses the necessary header to authenticate:

"Content-Type": "application/json", "X-CSRF": <csrf>, Authorization: "Basic " + btoa(<credentials>)

Next I’ve the user-id from the received response-data as a cookie-token:

// then set token cookie
authenticate().then((data) => {
  const token = useCookie("token"); // useCookie new hook in nuxt 3
  token.value = data.user.id; // set token to cookie
  if (!data.ok) {
     useRouter().push("/dashboard"); // redirect to login page
     this.authenticated = true; // set authenticated  state value to true
  }
});

final question: is this a safe approach? Can it do any harm if I take the user-id as a token? Because with that I can populate the dashboard with the user’s specific data.