Dans cette vidéo, je vous montre pas à pas comment mettre en place un système complet de gestion des utilisateurs et d’authentification dans votre application SvelteKit grâce à Lucia Auth. Vous apprendrez notamment comment intégrer simplement la connexion avec Google, gérer efficacement les sessions grâce aux jetons et cookies, tout en utilisant une base de données SQLite. Je vous partage également des astuces pratiques sur l’utilisation du gestionnaire de paquets pnpm et la base à connaître sur Git et GitHub pour bien débuter votre projet web. Ce tutoriel est parfaitement adapté aux développeurs débutants qui veulent apprendre à sécuriser leurs applications web et gérer facilement leurs utilisateurs.

⚠️
Le code vu dans cette vidéo est particulièrement adapté à SvelteKIT mais il est assez facilement adaptable à n'importe quel framework.

📦 Le code de départ de la vidéo précédente : https://github.com/tim-tiret/courses/tree/3cdda6a4e48203fcce8d8243fa12a464333ad8f0

📦 Le code complet est disponible ici : https://github.com/tim-tiret/courses/tree/34d3ff0dc81a52f8eb04e6eebf5cc71698aac803

Prérequis

Théorie sur l'authentification

Le client ne comprend que le HTML, le CSS et le JavaScript pour gérer l'interface et l'interactivité tandis que le serveur se chargé d'héberger l'application, de traiter les donnés et la sécurité. C'est lui qui décide d'accorder l'accès à une ressource à un utilisateur car l'on ne fait jamais confiance au client.

Différence client et serveur
Différence client et serveur

Pour créer un système d'authentification, 2 tables sont essentielles, celle des utilisateurs et celle des sessions. Une session est associée à un utilisateur, possède une date d'expiration et a également un Jeton. Ce jeton peut-être vu comme une carte d'identité qui permet à l'utilisateur de prouver que c'est lui. C'est une chaîne de caractères aléatoire (texte avec lettres aléatoires).

Tables utilisateurs et session SQL base de données pour authentification
Tables utilisateurs et session SQL base de données pour authentification

Quand on fait une requête sur une route nécessitant l'authentification (route protégée) mais que l'on a pas de jeton, le serveur nous refuse l'accès, même avec un s'il te plaît !

Ce qu'il se passe quand on fait une requête sur une route protégée sans jeton d'authentification
Ce qu'il se passe quand on fait une requête sur une route protégée sans jeton d'authentification

Mais lorsque l'on utilise un jeton valide, ça fonctionne quand même mieux ! Le système vérifie alors que la session existe et qu'elle n'est pas expirée et que l'utilisateur est autorisé à accéder à la ressource qu'il demande, puis si c'est le cas, il récupère l'info et lui renvoie.

Ce qu'il se passe quand on fait une requête sur une route protégée avec jeton d'authentification
Ce qu'il se passe quand on fait une requête sur une route protégée avec jeton d'authentification

Avant de pouvoir faire utiliser notre jeton, il est nécessaire de s'authentifier. Pour cela l'utilisateur a besoin de prouver qu'il est bien qui il prétend être. Soit via un couple identifiant mot de passe, soit avec par exemple une signature générée lors d'une connexion avec Google ou avec un autre service. Une fois la preuve vérifié, le serveur génère le jeton et crée la session en base de données. On enregistre alors un cookie pour se souvenir que l'utilisateur est bien connecté.

Le système d'authentification complet avec preuve, jeton, session et cookie
Le système d'authentification complet avec preuve, jeton, session et cookie

En pratique voici ce qu'il faut faire

Nous partons ici de l'application créée dans cette vidéo, que je vous invite à voir si ce n'est pas encore fait : https://youtu.be/DoJqLnP2Dhs

Je précise que si vous préférez, vous pouvez également vous inspirer de la doc officielle de Lucia :

Créer la base de données avec gestion des utilisateurs

import Database from 'better-sqlite3';

const connection = Database('courses.db');

connection.exec(`

CREATE TABLE user (
    id INTEGER NOT NULL PRIMARY KEY,
    google_id TEXT NOT NULL,
    email TEXT NOT NULL,
    username TEXT NULL
);


CREATE TABLE session (
    id TEXT NOT NULL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES user(id),
    expires_at INTEGER NOT NULL
);

CREATE TABLE list (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    article TEXT NOT NULL,
    user_id INTEGER NOT NULL
);
    `);

Installer les dépendances (Oslo et Arctic)

pnpm i @oslojs/encoding @oslojs/crypto arctic

Connexion à la base de données

Pour gérer la connexion à la base de données sans devoir la refaire à chaque fois je crée ce fichier : /src/lib/db.js

import Database from 'better-sqlite3';

export const db = Database('courses.db');
ℹ️
Le mot clé export permet que db soit accessible depuis n'importe quel fichier.

Gestions des session

Je crée ensuite un fichier dans mon lib : /src/lib/sessions.js

C'est ici que sont créées toutes les fonctions qui serviront à gérer mes sessions.

import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from './db';
import { sha256 } from '@oslojs/crypto/sha2';

export function generateSessionToken() {
	const bytes = new Uint8Array(20);
	crypto.getRandomValues(bytes);
	const token = encodeBase32LowerCaseNoPadding(bytes);
	return token;
}

export function createSession(token, userId) {
	const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
	const session = {
		id: sessionId,
		userId,
		expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30)
	};
	db.prepare('INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)').run([
		session.id,
		session.userId,
		Math.floor(session.expiresAt.getTime() / 1000)
	]);
	return session;
}

export function validateSessionToken(token) {
	const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
	const currentsession = db
		.prepare(
			'SELECT session.id, session.user_id, session.expires_at, user.id AS userid FROM session INNER JOIN user ON user.id = session.user_id WHERE session.id = ?'
		)
		.all([sessionId]);
	if (currentsession.length === 0) {
		return { session: null, user: null };
	}

	const session = {
		id: currentsession[0].id,
		userId: currentsession[0].userid,
		expiresAt: new Date(currentsession[0].expires_at * 1000)
	};
	const user = {
		id: currentsession[0].userid
	};
	if (Date.now() >= session.expiresAt.getTime()) {
		db.execute('DELETE FROM session WHERE id = ?', session.id);
		return { session: null, user: null };
	}
	if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
		session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
		db.execute(
			'UPDATE session SET expires_at = ? WHERE id = ?',
			Math.floor(session.expiresAt.getTime() / 1000),
			session.id
		);
	}
	return { session, user };
}

export function invalidateSession(sessionId) {
	db.prepare('DELETE FROM session WHERE id = ?').run(sessionId);
}

export async function invalidateAllSessions(userId) {
	await db.prepare('DELETE FROM user_session WHERE user_id = ?').run(userId);
}

Gestion des cookies de sessions

Pour que le système retienne la carte d'identité d'un utilisateur, on place un cookie, voici donc 2 fonctions à ajouter à notre fichier sessions.js qui permettent de faire cela.

export function setSessionTokenCookie(event, token, expiresAt) {
	event.cookies.set('session', token, {
		httpOnly: true,
		sameSite: 'lax',
		expires: expiresAt,
		path: '/'
	});
}

export function deleteSessionTokenCookie(event) {
	event.cookies.set('session', '', {
		httpOnly: true,
		sameSite: 'lax',
		maxAge: 0,
		path: '/'
	});
}

Middleware (vérification du jeton)

Afin de vérifier que l'utilisateur est connecté à chaque requête, l'on va créer ce que l'on appelle un middleware. C'est un système qui vérifie le jeton, la session et la validité et nous permet donc de récupérer l'utilisateur un peu partout côté serveur et de savoir s'il est connecté.

Pour cela je crée donc un fichier : /src/routes/hooks.server.js

import {
	deleteSessionTokenCookie,
	setSessionTokenCookie,
	validateSessionToken
} from '$lib/sessions';

export const handle = async ({ event, resolve }) => {
	const token = event.cookies.get('session') ?? null;
	if (token === null) {
		event.locals.user = null;
		event.locals.session = null;
		return resolve(event);
	}

	const { session, user } = await validateSessionToken(token);
	if (session !== null) {
		setSessionTokenCookie(event, token, session.expiresAt);
	} else {
		deleteSessionTokenCookie(event);
	}

	event.locals.session = session;
	event.locals.user = user;
	return resolve(event);
};

La fonction handle est automatiquement exécutée par SvelteKit à chaque requête. Je regarde donc s'il y a un token et si c'est le cas, je vérifie que la sessions est valide puis j'exécute resolve, c'est à dire que je passe à la suite (le code qui se trouve dans le route courante).

Crée un projet chez Google

Pour obtenir une preuve de la part de Google que l'utilisateur est bien la personne qu'il prétend être, il nous faut demande à Google un identifiant et un secret qui nous permettrons de récupérer les information de l'utilisateur et de garantir que la signature fournie par Google est bien valide.

Pour cela, il est nécessaire de créer un projet sur Google Cloud Platform : https://console.cloud.google.com/

Une fois le projet créé, on crée un client OAuth : https://console.cloud.google.com/auth/overview/create

Pour plus de détails, cela se passe à 38 minutes et 57 secondes dans la vidéo 😉...

Une fois récupérés, je vais stocker les identifiants dans un fichier de variables d'environnement à la racine de mon projet /.env

GOOGLE_CLIENT_ID="xxxxxxxxxx.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="GOXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXX"

Configurer le provider Google Auth

Dans mon fichier sessions.js, je vais ajouter le provider Google qui permet de configurer l'authentification avec Google.

import { Google } from 'arctic';
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from '$env/static/private';

// ... code existant ...

export const google = new Google(
	GOOGLE_CLIENT_ID,
	GOOGLE_CLIENT_SECRET,
	'http://localhost:5173/login/google/callback'
);

Générer le lien de connexion Google

Avec arctic, je peux maintenant créer un lien de connexion google qui me permettra de redirige l'utilisateur vers google le temps qu'il se connecte.

Je crée donc le fichier /src/routes/login/google/+server.js :

import { google } from '$lib/sessions';
import { generateState, generateCodeVerifier } from 'arctic';

export async function GET(event) {
	const state = generateState();
	const codeVerifier = generateCodeVerifier();
	const url = google.createAuthorizationURL(state, codeVerifier, ['openid', 'profile', 'email']);

	event.cookies.set('google_oauth_state', state, {
		path: '/',
		httpOnly: true,
		maxAge: 60 * 10, // 10 minutes
		sameSite: 'lax'
	});
	event.cookies.set('google_code_verifier', codeVerifier, {
		path: '/',
		httpOnly: true,
		maxAge: 60 * 10, // 10 minutes
		sameSite: 'lax'
	});

	return new Response(null, {
		status: 302,
		headers: {
			Location: url.toString()
		}
	});
}

Maintenant quand je me rends ou que je créer un lien vers localhost:5173/login/google, je suis redirigé sur Google afin de me connecter.

Je crée donc ce lien dans ce fichier : /src/routes/login/+page.svelte

<h1>Sign in</h1>
<a href="/login/google">Sign in with Google</a>

Callback Google

Une fois que l'utilisateur s'est connecté avec Google, je dois récupérer la signature, la vérifier, récupérer les informations de l'utilisateur et enfin le créer ou le connecter.

Pour cela, je crée le fichier /src/routes/login/google/callback/+server.js :

import { db } from '$lib/db';
import { createSession, generateSessionToken, google, setSessionTokenCookie } from '$lib/sessions';
import { decodeIdToken } from 'arctic';

export async function GET(event) {
	const code = event.url.searchParams.get('code');
	const state = event.url.searchParams.get('state');
	const storedState = event.cookies.get('google_oauth_state') ?? null;
	const codeVerifier = event.cookies.get('google_code_verifier') ?? null;
	if (code === null || state === null || storedState === null || codeVerifier === null) {
		return new Response(null, {
			status: 400
		});
	}
	if (state !== storedState) {
		return new Response(null, {
			status: 400
		});
	}

	let tokens;
	try {
		tokens = await google.validateAuthorizationCode(code, codeVerifier);
	} catch (e) {
		// Invalid code or client credentials
		return new Response(null, {
			status: 400
		});
	}
	const claims = decodeIdToken(tokens.idToken());
	const googleUserId = claims.sub;
	const username = claims.name;
	const email = claims.email;

	const existingUser = db.prepare('SELECT * FROM user WHERE google_id = ?').all([googleUserId]);

	if (existingUser.length !== 0) {
		const sessionToken = generateSessionToken();
		const session = await createSession(sessionToken, existingUser[0].id);
		setSessionTokenCookie(event, sessionToken, session.expiresAt);
		return new Response(null, {
			status: 302,
			headers: {
				Location: '/'
			}
		});
	}

	const user = db
		.prepare('INSERT INTO user (google_id, email, username) VALUES (?,?,?)')
		.run([googleUserId, email, username]);

	console.log(user);

	const sessionToken = generateSessionToken();
	const session = await createSession(sessionToken, user.lastInsertRowid);
	setSessionTokenCookie(event, sessionToken, session.expiresAt);
	return new Response(null, {
		status: 302,
		headers: {
			Location: '/'
		}
	});
}

Ça y est ! Lorsque je me connecte, j'ai maintenant un jeton et je suis 100% authentifié !

Adaptation multi utilisateurs de l'app

J'ai déjà adapté la base de données en ajoutant un user_id sur les produits, maintenant il faut que je modifie légèrement le backend pour prendre en compte cet user_id.

Je modifie donc mon fichier serveur : /src/routes/+page.server.js

import { fail, redirect } from '@sveltejs/kit';
import Database from 'better-sqlite3';

const connection = Database('courses.db');

export function load({ locals }) {
	if (!locals.user) {
		redirect(302, '/login');
	}
	console.log(locals.user.id);
	const listedecourse = connection
		.prepare('SELECT * FROM list WHERE user_id = ?')
		.all([locals.user.id]);
	return { courses: listedecourse };
}

export const actions = {
	createitem: async ({ request, locals }) => {
		if (!locals.user) {
			redirect(302, '/login');
		}
		console.log('create item');
		const formulaire = await request.formData();
		const nomarticle = formulaire.get('article');
		console.log(nomarticle);
		connection
			.prepare(`INSERT INTO list (article, user_id) VALUES (?, ?)`)
			.run([nomarticle, locals.user.id]);
	},
	deleteitem: async ({ request, locals }) => {
		if (!locals.user) {
			redirect(302, '/login');
		}
		console.log('delete item');
		const formulaire = await request.formData();
		const idarticle = formulaire.get('id');
		console.log(idarticle);
		connection
			.prepare(`DELETE FROM list WHERE id = ? AND user_id = ?`)
			.run([idarticle, locals.user.id]);
	}
};
ℹ️
Les points d'interrogations dans les requêtes SQL permettent de "préparer" les requêtes. Cela permet d'éviter les failles de sécurité d'injections SQL.

Déconnexion

La possibilité de se déconnecter n'existe pas encore, alors ajoutons la maintenant !

Pour cela, je vais d'abord ajouter un formulaire avec bouton de déconnexion à ma page principale : /src/routes/+page.svelte

<form action="?/logout" method="POST">
	<button type="submit"> Se déconnecter </button>
</form>

Ensuite, côté serveur, j'ajoute l'action logout qui me permet de supprimer la sessions et les cookies de l'utilisateur (dans /src/routes/+page.server.js).

import { deleteSessionTokenCookie, invalidateSession } from '$lib/sessions';

// ... code existant ...


export const actions = {
  // ... actions existantes ...
  logout: async (event) => {
		if (event.locals.session === null) {
			return fail(401);
		}
		await invalidateSession(event.locals.session.id);
		deleteSessionTokenCookie(event);
		return redirect(302, '/login');
	}
}

Conclusion

C'est terminé ! 🎉

Merci pour votre lecture, j'espère vous avoir aidé.

Comment gérer l'authentification et les utilisateurs avec Lucia Auth