#
Custom Authentication with Notion and Cookies
To do authentication with a Notion database we can use the following example Database structure
Users
Table
- Status (checkbox)
- ID (Formula id())
- Avatar (File)
- Full Name (String)
- Email Address (Email)
- Password (String)
- Company (Relation to Companies Table)
- Role (Multi-Select)
For the Companies
Database we have the following Columns:
- ID (Formula id())
- Company Name
- Description
- Contact Number
- Contact Person
#
Outline
- Set up Notion API and get the API key.
- Install the required packages for your Nuxt 3 application.
- Create a Pinia store for state management.
- Implement JWT authentication.
First, set up the Notion API by following the official guide: https://developers.notion.com/docs/getting-started
#
Installation
Next, install the required packages for your Nuxt 3 application:
npm install @types/cookie-signature @notionhq/client bcryptjs cookie-signature
- Create a
auth.ts
file in theplugins
folder
#
6.1 Plugins
//auth.ts
export default defineNuxtPlugin(async () => {
const { me } = useAuth();
await me();
});
#
6.2 Middleware
For Middleware I created 3 files:
- admin-only.ts
- guest-only.ts
- user-only.ts
// admin-only.ts
export default defineNuxtRouteMiddleware(async () => {
const isAdmin = useAdmin();
if (!isAdmin.value) return navigateTo({ name: "login" });
});
// guest-only.ts
export default defineNuxtRouteMiddleware(async () => {
const user = useAuthUser();
if (user.value) {
if (process.server) return navigateTo({ name: "index" });
return abortNavigation();
}
});
// user-only.ts
export default defineNuxtRouteMiddleware(async () => {
const user = useAuthUser();
if (!user.value) return navigateTo({ name: "login" });
});
#
6.3 Components
vue
// loginPage.vue
<script lang="ts" setup>
const emit = defineEmits(["success"]);
const { login } = useAuth();
const form = reactive({
data: {
avatar: "/img/avatars/defaultAvatar.png",
email: "charl@webally.co.za",
password: "9983538",
rememberMe: true,
roles: [],
company: "Private"
},
error: "",
pending: false,
});
async function onLoginClick() {
try {
form.error = "";
form.pending = true;
await login(form.data.email,form.data.password,form.data.rememberMe);
emit("success");
} catch (error: any) {
if (error.data.message) form.error = error.data.message;
} finally {
form.pending = false;
}
}
</script>
<template>
<p v-if="form.error" class="mb-3 text-red-500">
{{ ERROR }}
</p>
<div class="max-w-2xl mx-auto">
<div class="bg-white shadow-md border border-gray-200 rounded-lg max-w-sm p-4 sm:p-6 lg:p-8 dark:bg-gray-800 dark:border-gray-700">
<form
class="space-y-6"
@submit.prevent="onLoginClick">
<h3 class="text-xl font-medium text-gray-900 dark:text-white">
Sign in to Mall Chat Admin
</h3>
<div>
<button>Email and Password</button>
</div>
<div>
<label for="email" class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300">Your
email
</label>
<input
v-model="form.data.email"
type="email"
name="email"
id="email"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
placeholder="name@company.com"
required="true"
/>
</div>
<div>
<label for="password" class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300">Your
password
</label>
<input
v-model="form.data.password"
type="password"
name="password"
id="password"
placeholder="••••••••"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
required="true"
/>
</div>
<div class="flex items-start">
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="remember"
v-model="form.data.rememberMe"
aria-describedby="remember"
type="checkbox"
class="bg-gray-50 border border-gray-300 focus:ring-3 focus:ring-blue-300 h-4 w-4 rounded dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"
/>
</div>
<div class="text-sm ml-3">
<label for="remember" class="font-medium text-gray-900 dark:text-gray-300">
Remember me
</label>
</div>
</div>
<a href="#" class="text-sm text-blue-700 hover:underline ml-auto dark:text-blue-500">
Lost Password?
</a>
</div>
<button
type="submit"
:disabled="form.pending"
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
Sign In
</button>
<!--
<div class="text-sm font-medium text-gray-500 dark:text-gray-300">
Not registered? <a href="#" class="text-blue-700 hover:underline dark:text-blue-500">Create
account</a>
</div>
-->
</form>
</div>
<p class="mt-5">
There are no new registrations available right now
<br />
<a href="/contactUs" class="text-blue-600 hover:underline">
Contact Us
</a>
</p>
</div>
</template>
#
6.4 Composables
The following Composables
/composables/auth.ts
export { useAuthUser } from "./auth/useAuthUser";
export { useAuth } from "./auth/useAuth";
export { useAdmin } from "./auth/useAdmin";
/composables/auth/useAuth.ts
// useAuth.ts
import { useAuthUser } from "./useAuthUser";
import type { User } from "~~/types";
export const useAuth = () => {
const authUser = useAuthUser();
const setUser = (user: User | null) => {
authUser.value = user;
};
const setCookie = (cookie: any) => {
cookie.value = cookie;
};
const login = async (email: string, password: string, rememberMe: boolean) => {
const data:any = await $fetch("/auth/login", {
method: "POST",
body: {
email,
password,
rememberMe,
},
});
setUser(data.user);
return authUser;
};
const logout = async () => {
const data = await $fetch("/auth/logout", {
method: "POST",
});
setUser(data.user);
};
const me = async () => {
if (!authUser.value) {
try {
const data:any = await $fetch("/auth/me", {
headers: useRequestHeaders(["cookie"]) as HeadersInit,
});
setUser(data.user);
} catch (error) {
setCookie(null);
}
}
return authUser;
};
return {
login,
logout,
me
};
};
/composables/auth/useAdmin.ts
// useAdmin.ts
export const useAdmin = () => {
const authUser = useAuthUser();
return computed(() => {
if (!authUser.value) return false;
return authUser.value.roles.includes("Admin");
});
};
/composables/auth/useAuthUser.ts
// useAuthUser.ts`
import type { UserWithoutPassword } from "~~/types";
export const useAuthUser = () => {
return useState<UserWithoutPassword | null>("user", () => null);
};
#
6.5 Pages
- Create a
login.vue
file in thepages
folder
vue
<template>
<PageWrapper>
<PageHeader>
<PageTitle :text="$t('pages.login.title')" class="capitalize" />
</PageHeader>
<PageBody>
<PageSection>
<LoginPage @success="onLoginSuccess"/>
</PageSection>
</PageBody>
</PageWrapper>
</template>
<script lang="ts" setup>
import { capitalize } from '~/utils/str'
import LoginPage from '@/components/LoginPage.vue'
// composable
const { t } = useLang()
// compiler macro
definePageMeta({
layout: 'page',
middleware: ["guest-only"]
})
useHead(() => ({
title: capitalize(t('pages.login.title')),
meta: [
{
name: 'description',
content: t('pages.login.description'),
},
],
}))
const currentUser = useAuthUser();
const isAdmin = useAdmin();
async function onLoginSuccess() {
const redirect = isAdmin.value ? "/admin" : "/private";
await navigateTo(redirect);
}
</script>
#
6.6 Protecting routes
Here is an example of a page that can only be accessed by the Admin
user
Specifically have a look at definePageMeta
and middleware: ["admin-only"]
vue
// admin.vue
<script lang="ts" setup>
import { capitalize } from '~/utils/str'
// composable
const { t } = useLang()
definePageMeta({
middleware: ["admin-only"],
});
// compiler macro
definePageMeta({
layout: 'dashboard',
})
useHead(() => ({
title: capitalize(t('pages.admin.title')),
meta: [{
name: 'description',
content: t('pages.admin.description'),
}]
}));
const { data: users } = await useAsyncData("users", () =>
$fetch("/api/users",{
headers: useRequestHeaders(["cookie"]) as HeadersInit
})
);
const currentUser = useAuthUser();
</script>
<template>
<PageWrapper>
<PageHeader>
<PageTitle :text="$t('pages.admin.title')+':'+' '+currentUser.fullName" class="capitalize" />
</PageHeader>
<PageBody>
<PageSection>
<!--
<PageUser :user="currentUser"/>
-->
<div class="mb-3 p-3 text-light-100 shadow-lg shadow-black/20 dark:shadow-black/40">
<p class="text-lg text-weight-800">Users</p>
<table class="min-w-full text-left text-sm font-light">
<thead class="border-b font-medium dark:border-neutral-500">
<tr class="table-row">
<th scope="col" class="px-1 py-1"></th>
<th scope="col" class="px-1 py-1" style="width:20px">Status</th>
<th scope="col" class="px-6 py-4">Full Name</th>
<th scope="col" class="px-6 py-4">Email Address</th>
<th scope="col" class="px-6 py-4">Roles</th>
</tr>
</thead>
<tbody class="table-row-group">
<tr v-for="user in users" :key="user.id" class="border-b transition duration-300 ease-in-out hover:bg-neutral-100 dark:border-neutral-500 dark:hover:bg-neutral-600">
<td class="whitespace-nowrap px-1 py-1 font-medium"><img style="height:30px" :src="user.avatar"/></td>
<td class="whitespace-nowrap px-1 py-1" style="width:20px"><input type="checkbox" :checked="user.status" disabled/></td>
<td class="whitespace-nowrap px-6 py-4">{{ ERROR }}</td>
<td class="whitespace-nowrap px-6 py-4">{{ ERROR }}</td>
<td class="whitespace-nowrap px-6 py-4">{{ ERROR }}</td>
</tr>
</tbody>
</table>
</div>
</PageSection>
</PageBody>
</PageWrapper>
</template>
#
6.7 Protecting API routes
There some more files in the server/api
folder that you can use to protect your API routes
- login.post.ts
- notion.js
#
6.8 Server Middleware
/server/middleware/session.ts
// session.ts
export default defineEventHandler(async (event) => {
const user = await getUserFromSession(event);
if (user) event.context.user = user;
});
#
6.8 Server Models
/server/models/user.ts
// user.ts
import { Client } from "@notionhq/client";
import type { NotionApiResponse, User } from "~~/types";
import { mapNotionApiResponseToUser } from "~/utils/notion/mapUsers";
import { AnyAaaaRecord } from "dns";
const notion = new Client({ auth: process.env.NOTION_API_KEY! });
const DB = process.env.NOTION_USERS_DB!;
export async function getUsers() {
const userData = await notion.databases.query({
database_id: DB
});
return mapNotionApiResponseToUser(userData);
}
export async function getUserByEmail(email: string) {
const data:any = await notion.databases.query({
database_id: DB,
filter: {
property: 'Email Address',
rich_text: {
equals: email,
},
},
});
return mapNotionApiResponseToUser(data)[0];
}
export async function getUserByEmailAndPassword(email: string, password:string):Promise<User> {
const data:any = await notion.databases.query({
database_id: DB,
filter: {
and: [{
property: 'Email Address',
rich_text: {
equals: email,
},
},{
property: 'Password',
rich_text: {
equals: password,
},
},
],
}
});
return mapNotionApiResponseToUser(data.results)[0];
}
export async function getUserById(id: number) {
const data:any = await notion.databases.query({
database_id: DB,
filter: {
property: 'ID',
number: {
equals: id,
},
},
});
return mapNotionApiResponseToUser(data.results)[0];
}
export async function isAdmin(user?: User) {
return user && user.roles.includes("Admin");
}
#
6.9 Server Routes
/server/routes/auth/login.post.ts
// login.post.ts
import { getUserByEmailAndPassword } from "~~/server/models/user";
export default defineEventHandler(async (event) => {
const body = await readBody<{ email: string; password: string; rememberMe: boolean }>(event);
const { email, password, rememberMe } = body;
if (!email || !password) {
return createError({
statusCode: 400,
message: "Email address and password are required",
});
}
const userWithPassword = await getUserByEmailAndPassword(email,password);
if (!userWithPassword) {
return createError({
statusCode: 401,
message: "Bad credentials"
});
}
if (!userWithPassword.status) {
return createError({
statusCode: 401,
message: "User Deactivated"
});
}
/* When passwords will be encrypted for now the
password is verified by filtering on email and password from notion
const verified = await verify(password, userWithPassword.password);
if (!verified || userWithPassword) {
return createError({
statusCode: 401,
message: "Bad credentials",
});
}
*/
const config = useRuntimeConfig();
const session = serialize({ userId: userWithPassword.id });
const signedSession = sign(session, config.cookieSecret);
setCookie(event, config.cookieName, signedSession, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
expires: rememberMe
? new Date(Date.now() + config.cookieRememberMeExpires)
: new Date(Date.now() + config.cookieExpires),
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password: _password, ...userWithoutPassword } = userWithPassword;
return {
user: userWithoutPassword,
};
});
/server/routes/auth/logout.post.ts
// logout.post.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
deleteCookie(event, config.cookieName, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
});
return {
user: null,
};
});
/server/routes/auth/me.get.ts
// me.get.ts
export default defineEventHandler(async (event) => {
const userWithPassword = event.context.user;
if (!userWithPassword) {
return {
user: null,
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password: _password, ...userWithoutPassword } = userWithPassword;
return {
user: userWithoutPassword,
};
});
#
6.10 Server Utils
/server/utils/password.ts
// password.ts
import bcrypt from "bcryptjs";
export async function hash(plainPassword: string) {
return bcrypt.hash(plainPassword, 10);
}
export function verify(plainPassword: string, hash: string) {
return bcrypt.compare(plainPassword, hash);
}
/server/utils/session.ts
// session.ts
import type { H3Event } from "h3";
import cookieSignature from "cookie-signature";
import { getUserById } from "~~/server/models/user";
export function serialize(obj: any) {
const value = Buffer.from(JSON.stringify(obj), "utf-8").toString("base64");
const length = Buffer.byteLength(value);
if (length > 4096) throw new Error("Session value is too long");
return value;
}
export function deserialize(value: string) {
return JSON.parse(Buffer.from(value, "base64").toString("utf-8"));
}
export function sign(value: string, secret: string) {
return cookieSignature.sign(value, secret);
}
export function unsign(value: string, secret: string) {
return cookieSignature.unsign(value, secret);
}
export async function getUserFromSession(event: H3Event) {
const config = useRuntimeConfig();
const cookie = getCookie(event, config.cookieName);
if (!cookie) return null;
const unsignedSession = unsign(cookie, config.cookieSecret);
if (!unsignedSession) return null;
const session = deserialize(unsignedSession);
return await getUserById(session.userId);
}
#
6.11 Server Utils
- Service
/services/notion.ts
const notionApiOptions = {
baseURL: 'https://api.notion.com/v1',
headers: {
Authorization: `Bearer ${process.env.NOTION_API_KEY}`,
'Notion-Version': '2021-08-16', // Replace with the desired Notion API version
},
}
export function useNotionDatabase(databaseId) {
const { data: results = [], loading, error } = useFetch(`/databases/${databaseId}/query`, notionApiOptions)
const resultss = data.results || []
return {
resultss,
loading,
error,
}
}