Implémentation d’une authentification SSR avec Firebase et Svelte
J’ai commencé à regarder Svelte et je l’ai adoré jusqu’à présent. Cependant, j’ai eu du mal à faire certaines choses. Je suppose que c’est parce que la communauté est plus petite que React, par exemple, donc il n’y a pas beaucoup de ressources couvrant tout. C’est une tentative d’atténuer cela.
Ce que je veux réaliser aujourd’hui, c’est avoir une application qui gère l’authentification avec Firebase et qui est toujours un rendu côté serveur.
Un petit avertissement ici, je raconte cet article dans un guide étape par étape, mais chaque étape est fonctionnelle. Cela signifie que je crée un exemple de travail, qui fonctionne uniquement côté client, puis que je le modifie jusqu’à obtenir la version SSR. Si ce que vous voulez, c’est la version soignée, je vous recommande d’aller directement à la section Résumé.
J’ai fourni ici quelques extraits de code que vous pouvez copier-coller, mais je vous recommande également d’examiner mon référentiel. Le première RP en particulier, car il contient tout le code nécessaire pour cet article particulier.
Assez d’intro, commençons les choses.
Je suppose que vous avez déjà un projet Firebase et que vous connaissez un peu le tableau de bord.
Nous commencerons par une seule authentification Email/Mot de passe. Firebase fournit également une intégration via OAuth avec des fournisseurs tels que Facebook et Google, mais nous ne les couvrirons pas ici.
Pour commencer, accédez au tableau de bord d’authentification dans Firebase et accédez à l’onglet Méthode de connexion. Activez la connexion par e-mail/mot de passe. (Cela permet également l’enregistrement, mais cela sort du cadre de cet article)
Ajoutez maintenant une WebApp, si vous ne l’avez pas déjà fait, et ajoutez firebase à votre projet. Ici, je vais utiliser yarn
yarn install firebase
Firebase vous donne également un extrait pour initialiser Firebase. Copiez cela dans src/lib/client/firebase.ts
.
Cependant, ce fichier initialiserait firebase lors de l’importation, et ce fichier pourrait également être ajouté côté serveur. Ceci est problématique car ce fichier est uniquement destiné au côté client. getAnalytics
échouerait par exemple. Ensuite, nous avons besoin d’un peu plus de contrôle. Nous pouvons encapsuler cela dans une fonction qui initialisera Firebase à la commande. Cependant, nous souhaitons également réutiliser une instance si elle a été créée au préalable. Ceci est résolu en mémorisant la fonction. j’utiliserai lodash
pour ça.
Initialisons également le auth
module à la fois. Avec cette considération, nous réécrirons l’initialisation comme suit :
import { memoize } from 'lodash';
import { initializeApp } from 'firebase/app';
import { getAnalytics } from 'firebase/analytics';
import { getAuth } from 'firebase/auth';// ... Firebase Config ...
// Initialize Firebase
export const initFirebase = memoize(() => {
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);
const auth = getAuth(app);
return { app, analytics, auth }
})
Notez que j’ajoute déjà auth
. Pour cela, nous devons import { getAuth } from 'firebase/auth';
Avec cela, nous avons maintenant un moyen de configurer firebase
d’ailleurs dans notre application. Il ne nous reste plus qu’à importer ce fichier. Nous le ferons dans notre mise en page principale. Pour cela ouvrez (ou créez, si vous ne l’avez pas déjà fait) le fichier src/routes/+layout.svelte
et ajoutez cet extrait
<script>
import { onMount } from 'svelte';
import { initFirebase } from '$lib/client/firebase';onMount(initFirebase);
</script>
Depuis onMount
s’exécute uniquement sur le client, nous pouvons être sûrs que Firebase n’est désormais initialisé que du côté client.
Maintenant, nous avons besoin d’un endroit pour stocker l’utilisateur. Allons de l’avant et créons src/stores/auth.ts
fichier pour stocker notre objet utilisateur.
import { writable } from 'svelte/store';type User = {
uid: string;
email: string;
};
export const auth = writable<User | null>(null);
Maintenant, nous avons juste besoin d’un moyen de mettre à jour le magasin chaque fois que l’utilisateur est mis à jour. C’est facile maintenant, retournez à +layout.svelte
et ajouter ceci :
// ...
import { auth as authStore } from '../stores/auth'// ...
onMount(() => {
const { auth } = initFirebase();
onAuthStateChanged(auth, authStore.set)
})
Désormais, chaque fois que Firebase mettra à jour l’utilisateur authentifié, nous l’aurons dans notre magasin. Maintenant, nous pouvons le saisir à partir de là chaque fois que nous en avons besoin.
Le final pièce est juste la connexion et la déconnexion réelles. Continuons et créons une page de connexion simple. j’utiliserai src/routes/login/+page.svelte
Je n’entrerai pas dans les détails de la création du formulaire pour ne pas encombrer cet article, mais vous pouvez toujours accéder au référentiel fourni pour Vérifiez-le pleinement. Je ne mentionnerai que les parties importantes.
j’ajoute un simple onMount
fonction pour rediriger l’utilisateur vers la page d’accueil chaque fois que la connexion est réussie.
import { goto } from "$app/navigation";
import { onMount } from "svelte";onMount(() => {
return auth.subscribe((user) => {
if (user) {
goto('/')
}
});
});
Pour faire la connexion proprement dite, j’ai utilisé la méthode signInWithEmailAndPassword
de firebase/auth
qui reçoit un auth
exemple (nous pouvons déjà obtenir cela à partir de notre initialiseur ou en utilisant getAuth
), email
et password
. Ceux que nous devons obtenir à partir du formulaire.
Et enfin, nous devons mettre à jour notre magasin chaque fois qu’un utilisateur est connecté. Pour cela, j’utiliserai un autre onMount
rappel sur notre mise en page, en écoutant l’état d’authentification. Cela définira l’utilisateur dans notre magasin lorsque le statut d’authentification changera.
Cependant, il y a quelques problèmes avec cette approche. Pour mieux les montrer, j’ai créé le composant AuthStatus.svelte
en dessous de src/components
. Ce composant indique uniquement si quelqu’un est authentifié ou non. Si vous payez cette versionvous remarquerez qu’après une connexion, les actualisations ultérieures de la page afficheront Personne n’est authentifié pendant une seconde et changer pour Authentifié en tant que … après quelques millisecondes. Cela se produit parce que lorsque nous obtenons la page du serveur, nous n’obtenons aucune donnée d’authentification. Le serveur ne sait pas que quelqu’un est réellement authentifié et renvoie donc la page comme si un invité la visitait.
Alors, comment donner au serveur le contexte de la session que nous créons sur le client ?
L’utilisateur firebase a des méthodes intégrées pour obtenir un jeton, qui peuvent être vérifiées ultérieurement côté serveur. Je vais donc écrire une action qui recevra le jeton du client et demandera au navigateur de le stocker dans un cookie :
// src/routes/login/+page.server.ts
export const actions: Actions = {
default: async ({ request, cookies }) => {
const formData = await request.formData();
const token = formData.get('token')?.valueOf();
if (!token || typeof token !== 'string') {
return fail(400, { message: 'Token is a required field and must be a string' });
}
cookies.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
path: '/',
secure: true
});
return { success: true };
}
};
Ainsi, après avoir obtenu l’utilisateur de firebase, nous obtiendrons le jeton et l’enverrons à cette action :
formData.set('token', await user.getIdToken());
const response = await fetch(this.action, {
method: 'POST',
body: formData,
});const result = deserialize(await response.text());
if (result.type === 'success') {
await invalidateAll();
}
Dans cet extrait, j’inclus déjà un appel à invalidateAll
. En effet, potentiellement, toutes les données que nous avons chargées lorsque nous étions un utilisateur (non) authentifié ne sont plus valides, comme les données utilisateur, les préférences, etc. Cela deviendra plus utile dans un moment.
Jusqu’à présent, nous recevons le jeton sur le serveur à chaque requête, mais nous ne le validons pas et ne faisons rien avec. Il est donc temps d’écrire un hook de serveur et de commencer à remplir l’utilisateur côté serveur également.
Pour valider un token de firebase côté serveur, je vais utiliser le package firebase-admin
. Installez-le avec
yarn add firebase-admin
Pour commencer à l’utiliser, nous devons donner les informations d’identification du serveur. Pour cela, rendez-vous dans votre tableau de bord Firebase → Paramètres du projet → Onglet Comptes de service. Là, vous aurez la possibilité de générer une nouvelle clé privée pour votre SDK Firebase.
Cela téléchargera un fichier JSON. Il est particulièrement important de conserver les données de ce fichier JSON en toute sécurité et il n’est donc pas recommandé de l’ajouter directement à votre référentiel. Je ne couvrirai pas comment le garder en sécurité ici, donc pour les besoins de cet article, je vais juste le stringifier et le garder dans .env.local
. Ce fichier n’est pas suivi par git et les variables ici sont automatiquement disponibles dans $env/static/private
À partir de l’exemple du client et de ce que vous avez vu sur la page firebase pour télécharger votre clé privée, vous pouvez imaginer comment init firebase sur le serveur, donc je ne mettrai pas le code ici.
Outre l’initialisation, nous voulons qu’une fonction décode un jeton de firebase. Nous utiliserons admin.auth().verifyIdToken(token)
avec un petit emballage pour détecter les erreurs et revenir null
s’ils se produisent, l’expérience n’est donc pas affectée si le jeton a expiré par exemple.
export async function decodeToken(
token: string
): Promise<DecodedIdToken | null> {
if (!token) {
return null;
}
try {
initializeFirebase();
return await admin.auth().verifyIdToken(token);
} catch (err) {
console.error('An error occurred validating token', (err as Error).message);
return null;
}
}
Maintenant que nous avons cette fonction, il nous suffit de décoder le jeton du cookie à chaque requête. Comme je l’ai mentionné, je vais utiliser un hook de serveur.
export const handle = (async ({ event, resolve }) => {
const cookies = cookie.parse(event.request.headers.get('cookie') || '');
const token = cookies[SESSION_COOKIE_NAME];
if (token) {
const decodedToken = await decodeToken(token);
if (decodedToken) {
event.locals.user = decodedToken;
}
}
return resolve(event);
}) satisfies Handle;
Attention j’ajoute user
au event.locals
objet. des locaux object est un moyen de partager des données entre des fonctions côté serveur comme d’autres crochets et chargeurs. Si vous allez copier-coller ceci dans votre hooks.server.ts
fichier, vous obtiendrez une erreur indiquant que user
n’est pas une propriété valide pour le Locals
objet. Pour résoudre ce problème, remplacez le Locals
interface sous app.d.ts
. Je vais simplement le remplacer comme suit :
declare namespace App {
// interface Error {}
interface Locals {
user: null | {
uid: string;
email?: string;
};
}
// interface PageData {}
// interface Platform {}
}
Désormais, chaque appel au serveur décode le jeton et définit event.locals.user
. La dernière pièce consiste à utiliser cet utilisateur décodé. Je vais commencer à le fournir dans un chargeur dans le fichier de mise en page principal afin tout le monde peut le réutiliser. Ainsi, sous src/routes/+layout.server.ts
on met ça :
import type { LayoutServerLoad } from './$types';
export const load = (async (event) => {
const user = event.locals.user;
return { user }
}) satisfies LayoutServerLoad;
Et enfin, vous souvenez-vous que nous avons stocké notre utilisateur dans un magasin ? Ce magasin était étrange parce qu’il gardait juste un objet, représentant l’utilisateur, mais n’avait vraiment aucune logique, mais maintenant, nous avons l’utilisateur dans le magasin de pages. Cela ressemble plus à un magasin dérivé pour moi. Mettons à jour le magasin d’authentification comme ceci :
import { page } from '$app/stores';
import { derived } from 'svelte/store';type User = {
uid: string;
email?: string;
};
export const auth = derived<typeof page, User | null>(
page,
($page, set) => {
const { user } = $page.data;
if (!user) {
set(null);
return;
}
set(user);
},
null
);
Cela commencera automatiquement à utiliser l’utilisateur de la session que nous avons stockée. Maintenant, essayez d’actualiser l’application ou d’examiner le code HTML brut renvoyé par le serveur. Vous remarquerez que maintenant, nous avons effectivement obtenu des données authentifiées renvoyées par le serveur, nous avons donc récupéré notre SSR complet.
Il reste cependant une petite chose. Nous ne pouvons pas nous déconnecter pour le moment. Pour cela, je vais maintenant ajouter le fichier src/routes/logout/+server.ts
avec un simple POST
action qui supprimera le cookie
import { SESSION_COOKIE_NAME } from '$lib/constants';
import type { RequestHandler } from '@sveltejs/kit';
import cookie from 'cookie';export const POST = (async () => {
return new Response('', {
headers: {
'set-cookie': cookie.serialize(SESSION_COOKIE_NAME, '', {
path: '/',
httpOnly: true,
maxAge: -1,
})
}
});
}) satisfies RequestHandler;
Et je vais juste l’appeler avec fetch
chaque fois que l’utilisateur clique sur Se déconnecter bouton
const logout = async () => {
const firebaseAuth = getAuth();
await signOut(firebaseAuth);
await fetch('/logout', { method: 'POST' });
await invalidateAll();
}
Maintenant, appelant invalidateAll
est encore plus logique, car vous remarquerez qu’appeler cela récupèrera les données de l’utilisateur, sans jeton. Par conséquent, toutes les données seront rechargées sans session et mettront à jour la page en conséquence.
- À l’heure actuelle, la seule façon de se connecter est via la page de connexion, après avoir entré l’e-mail et le mot de passe de l’utilisateur. Si vous souhaitez vous connecter depuis un autre endroit, comme dans la barre de navigation, et/ou intégrer des tiers, vous devrez peut-être mettre à jour la logique sur la façon dont vous envoyez le jeton au serveur.
- L’utilisateur que nous stockons vient directement du serveur ! À l’heure actuelle, c’est tout ce que Firebase renvoie, mais si vous en avez besoin, vous pouvez également y charger des données supplémentaires, à partir de n’importe quelle source dont vous avez besoin. Avez-vous un avatar stocké ailleurs ? Pas de problème, chargez-le simplement dans le hook du serveur et le client l’obtiendra automatiquement.