Authentifier les utilisateurs du front-end au back-end
Fait partie de la série Améliorer votre blog VitePress avec une API Laravel
Maintenant que le front-end peut joindre l'API Laravel, nous pouvons utiliser cette connexion pour gérer l'authentification dans toute l'application.
Jusqu'ici, nous avons testé des requêtes isolées. Dans cet article, nous récupérerons l'utilisateur courant au démarrage de l'application, afficherons l'action de connexion ou de déconnexion appropriée et implémenterons la déconnexion avec une mise à jour optimiste. L'interface se mettra à jour immédiatement pendant l'exécution de la requête, puis reviendra à son état précédent si la requête échoue.
Pour gérer cela proprement, nous combinerons Pinia avec Pinia Colada, une bibliothèque de récupération de données construite au-dessus de Pinia et créée par Eduardo, le créateur de Pinia. Elle nous fournira un état partagé mis en cache, l'état des requêtes et les hooks nécessaires aux mises à jour optimistes.
Configurer l'API
Pour prendre en charge le front-end, nous avons besoin de deux nouvelles routes dans l'application Laravel :
GET /api/user: Retourne l'utilisateur authentifié. Le front-end l'appelle au démarrage de l'application pour adapter l'interface.POST /logout: Déconnecte l'utilisateur et invalide la session courante lorsque le bouton de déconnexion du front-end est utilisé.
Pour chaque route, nous écrirons des tests, créerons une méthode de contrôleur et enregistrerons la route. C'est le même processus que dans les articles précédents.
Retourner l'utilisateur
Commençons par la première route. Créons un test pour vérifier que l'utilisateur est authentifié et que ses données sont correctement retournées.
<?php
declare(strict_types=1);
use Illuminate\Testing\Fluent\AssertableJson;
it('requires authentification', function () {
$this->getJson(route('user.show'))
->assertUnauthorized();
});
it('returns the authenticated user', function () {
$user = App\Models\User::factory()->create();
$response = $this->actingAs($user)
->getJson(route('user.show'));
$response
->assertOk()
->assertJson(
fn (AssertableJson $json) => $json
->has(
'data',
fn (AssertableJson $json) => $json
->where('name', $user->name)
->where('username', $user->username)
->where('avatar', $user->avatar)
)
);
});Ces tests ressemblent à ceux des commentaires. Le premier vérifie l'authentification et le second contrôle les données de l'utilisateur dans la réponse JSON.
Créons maintenant le contrôleur :
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class UserController
{
public function show(Request $request): JsonResource
{
return UserResource::make($request->user());
}
}Ce contrôleur utilise UserResource pour transformer l'utilisateur authentifié en réponse JSON. Nous avons créé cette ressource dans l'article consacré au système de commentaires ; elle expose les champs name, username et avatar.
Enregistrons enfin la route dans routes/api.php :
<?php
declare(strict_types=1);
use App\Http\Controllers\Api\UserController;
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', [UserController::class, 'show'])
->name('user.show');
// ...
});Cela suffit pour récupérer l'utilisateur courant.
Déconnecter l'utilisateur
Commençons par les tests :
<?php
declare(strict_types=1);
use App\Models\User;
it('requires authentification', function () {
$this->postJson(route('logout'))
->assertUnauthorized();
});
it('destroys the session', function () {
$response = $this->actingAs(User::factory()->create())
->post(route('logout'))
->assertNoContent();
$this->assertGuest('web');
$response->assertNoContent();
});Ces tests suivent les mêmes patterns que les précédents.
Créons maintenant le contrôleur :
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
final class AuthenticatedSessionController
{
public function destroy(Request $request): Response
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
}
}Le contrôleur utilise la façade Auth de Laravel pour déconnecter l'utilisateur. Il invalide ensuite la session et régénère le jeton CSRF. Ces étapes contribuent à prévenir les attaques par fixation de session.
Note
Le contrôleur retourne une réponse vide, car le front-end n'a pas besoin d'un corps de réponse pour la déconnexion.
Enregistrons enfin la route dans routes/web.php :
use App\Http\Controllers\Auth\AuthenticatedSessionController;
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->middleware('auth:sanctum')
->name('logout');Cette route utilise le middleware auth:sanctum même si elle est définie dans web.php. Elle ne retourne pas de JSON et n'a pas besoin d'être versionnée avec les autres routes d'API ; la conserver comme route web est donc raisonnable.
Dans le fichier de configuration config/cors.php, nous devons également ajouter la route logout au tableau paths :
return [
'paths' => ['api/*', 'logout'],
// ...
];Cela permet au front-end d'appeler la route depuis une autre origine. Sans cette configuration, le navigateur bloquerait la requête à cause de CORS.
Préparer le front-end
Le back-end est prêt, passons au front-end.
Nous récupérerons l'utilisateur au démarrage de l'application afin que l'interface puisse réagir à l'état d'authentification. Plusieurs composants ont besoin de cette information. L'en-tête affiche actuellement un bouton de connexion ; nous ajouterons un bouton de déconnexion pour les utilisateurs authentifiés et masquerons le bouton de connexion dans ce cas.
Le pied de page pourrait également avoir besoin d'un lien de connexion ou de déconnexion, et le système de commentaires utilisera le même état pour décider s'il doit afficher un bouton de connexion ou un formulaire de commentaire.
La solution la plus simple serait un composable partagé :
const user = ref(null)
export function useUser() {
async function fetch() {
// Simulate fetching user data from the backend
user.value = await fetchUserData()
}
return {
user,
fetch,
}
}Note
Nous pourrions aussi utiliser createSharedComposable de VueUse pour éviter un état global tout en conservant la même instance du composable dans tous les composants.
Nous appellerions ensuite fetch dans le hook onMounted du composant principal, Layout.vue dans notre cas.
Cette solution fonctionne un temps, mais devient rapidement difficile à maintenir. Un composant effectue la récupération tandis que beaucoup d'autres consomment le résultat. Cela reste gérable pour un seul type de données, mais devient désordonné à mesure que l'application grandit.
Nous aurions également besoin de code répétitif pour gérer l'état des mutations. Se déconnecter, créer un commentaire et supprimer un commentaire nécessitent tous un état de chargement et d'erreur. Répéter ce code rendrait les composants verbeux ou nous pousserait à créer un ensemble d'utilitaires personnalisés.
Nous pourrions aussi vouloir des mises à jour optimistes. La déconnexion en est un bon exemple : retirer immédiatement l'utilisateur de l'état, appeler l'API, puis restaurer l'utilisateur si la requête échoue. Implémenter cela de manière fiable et générique n'est pas trivial.
Pour éviter ce code répétitif, nous allons combiner Pinia et Pinia Colada.
- Pinia est la bibliothèque de gestion d'état de Vue. Un store centralise l'état, les getters et les actions, tandis que l'interface réagit aux changements de cet état.
- Pinia Colada est une bibliothèque de récupération de données construite au-dessus de Pinia. Elle gère les requêtes et l'état du cache, et fournit les hooks nécessaires aux mises à jour optimistes.
Installez Pinia et Pinia Colada dans le projet Garabit :
pnpm install pinia @pinia/coladaEnregistrons-les ensuite dans notre application Vue :
import { PiniaColada } from '@pinia/colada'
import { createPinia } from 'pinia'
export default {
Layout,
enhanceApp({ app }) {
app.use(createPinia())
app.use(PiniaColada)
},
} satisfies ThemeLes deux packages sont des plugins Vue qui ajoutent des fonctionnalités au niveau de l'application.
Supprimez le code de requête temporaire de .vitepress/theme/Layout.vue ; il sera remplacé par le client API partagé.
Avant d'écrire le code de la fonctionnalité, définissons sa structure. Dans common/utils/api.ts, nous allons créer une instance personnalisée de ofetch afin que chaque requête utilise le même hôte, les mêmes en-têtes, les mêmes identifiants et la même gestion CSRF. C'est le code introduit dans l'article précédent.
Chaque fonctionnalité contiendra deux dossiers et un fichier API :
queries: Contient les requêtes qui récupèrent des données.GET /api/userest une query.mutations: Contient les requêtes qui modifient des données.POST /logoutest une mutation.api.ts: Contient les fonctions bas niveau qui effectuent les requêtes. Ce fichier reste indépendant de la bibliothèque de gestion d'état, ce qui rend la couche API facile à lire et à tester.
Ces dossiers et ce fichier API existeront pour chaque fonctionnalité. Aujourd'hui, nous allons créer la fonctionnalité user. L'article suivant ajoutera une fonctionnalité comments. Cette séparation rassemble le code lié et facilite sa recherche.
Étendre ofetch
Créons l'instance personnalisée de ofetch dans .vitepress/theme/common/utils/api.ts :
import { ofetch } from 'ofetch'
export const api = ofetch.create({
baseURL: import.meta.env.VITE_API_URL,
headers: {
Accept: 'application/json',
},
credentials: 'include',
onRequest: async ({ options }) => {
const method = options.method?.toUpperCase() || 'GET'
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
return
}
const xsrfToken = getCookie('XSRF-TOKEN')
if (!xsrfToken) {
await ofetch('/sanctum/csrf-cookie', {
baseURL: import.meta.env.VITE_API_URL,
credentials: 'include',
})
}
options.headers.set(
'X-Xsrf-Token',
decodeURIComponent(getCookie('XSRF-TOKEN') || ''),
)
},
})
function getCookie(name: string) {
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))
return match ? match[2] : null
}Tout importer automatiquement
Comme pour la configuration de la série, mettez à jour .vitepress/config.mts pour importer automatiquement les nouveaux utilitaires, queries, mutations et composables Pinia Colada.
const currentDir = dirname(fileURLToPath(import.meta.url))
const themeDir = resolve(currentDir, 'theme')
const utilsDir = resolve(themeDir, '**', 'utils')
const queriesDir = resolve(themeDir, '**', 'queries')
const mutationsDir = resolve(themeDir, '**', 'mutations')
const apiFile = resolve(themeDir, '**', 'api.ts')
export default defineConfig({
vite: {
plugins: [
AutoImport({
imports: [
{
from: '@pinia/colada',
imports: [
'defineQuery',
'useQuery',
'defineMutation',
'useMutation',
'useQueryCache',
],
},
],
dirs: [composablesDir, utilsDir, queriesDir, mutationsDir, apiFile],
}),
],
},
})Toutes les fonctions exportées depuis les dossiers utils, queries et mutations sont maintenant disponibles dans toute l'application.
Créer la fonctionnalité utilisateur
Suivons maintenant notre plan.
Créons la fonctionnalité user :
mkdir -p .vitepress/theme/user/{queries,mutations}
touch .vitepress/theme/user/api.tsÉcrivons ensuite le code qui récupère les données utilisateur et déconnecte l'utilisateur dans .vitepress/theme/user/api.ts :
export const fetchUser = () => api('api/user')
export const postLogoutUser = () => api('logout', { method: 'POST' })Ces fonctions abstraient les appels API bruts. Le reste de la fonctionnalité peut utiliser les noms explicites fetchUser et postLogoutUser sans connaître les détails de la requête.
Note
Comment nommer les fonctions API ? J'utilise le préfixe fetch pour les queries, suivi du nom de la ressource. Pour les mutations, j'utilise le verbe HTTP suivi du nom de la ressource. Les fonctions sont ainsi faciles à reconnaître et à retenir.
Créons maintenant la query qui récupère les données utilisateur :
import type { User } from '@/user/types/user'
export const useUser = defineQuery(() => {
return useQuery<User>({
key: ['user'],
query: () => fetchUser().then(response => response.data),
enabled: typeof document !== 'undefined',
})
})Nous devons également définir un nouveau type pour l'utilisateur :
export interface User {
id: number
name: string
username: string
avatar: string
}Voici le fonctionnement de la query :
defineQuerydéfinit une query réutilisable dans le contexte Vue.useQuerydécrit la clé, la fonction de requête et l'état retourné aux composants.keyidentifie la query dans le cache de Pinia Colada. LorsqueuseUserest appelée à nouveau, Pinia Colada peut réutiliser un résultat récent en cache au lieu d'envoyer une autre requête.queryretourne la promesse de la requête API. Nous retournons la propriétédatade la réponse, car les ressources Laravel encapsulent leur contenu dansdata.enabledlimite la requête au navigateur. Nous ne voulons pas appeler l'API lorsque VitePress génère le site.
Nous pouvons maintenant appeler useUser n'importe où dans l'application. Les composants n'ont pas besoin d'attendre la requête ; ils consomment son état réactif et laissent Vue mettre à jour l'interface lorsque les données arrivent.
Pinia Colada déduplique également les requêtes, recharge les données obsolètes si nécessaire, expose le cache et fournit des hooks pour les mises à jour optimistes.
Note
La déduplication est importante, car plusieurs composants peuvent appeler useUser sur la même page. Pinia Colada effectue une seule requête API et partage le résultat entre ces appels.
Écrivons maintenant la mutation qui déconnecte l'utilisateur :
import type { User } from '@/user/types/user'
export const useLogoutUser = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({
mutation: () => postLogoutUser(),
onMutate: () => {
const oldUser = queryCache.getQueryData<User>(['user'])
queryCache.setQueryData(['user'], null)
queryCache.cancelQueries({ key: ['user'], exact: true })
return { oldUser }
},
onError: (_, __, { oldUser }) => {
if (oldUser) {
queryCache.setQueryData(['user'], oldUser)
}
},
onSettled: () =>
queryCache.invalidateQueries({ key: ['user'], exact: true }),
})
})Cette mutation utilise une mise à jour optimiste. Lorsque l'utilisateur clique sur « Déconnexion », onMutate retire immédiatement l'utilisateur du cache. L'interface remplace donc le bouton de déconnexion par celui de connexion sans attendre le serveur. La mutation retourne l'ancien utilisateur afin que onError puisse le restaurer si la requête échoue. onSettled s'exécute dans les deux cas et invalide la query utilisateur, ce qui permettra à Pinia Colada de la rafraîchir lors de sa prochaine utilisation.
Mettez à jour l'en-tête pour appeler useUser et afficher le bouton approprié :
<script lang="ts" setup>
const { state } = useUser()
</script>
<template>
<header>
<div>
<!-- ... -->
<div>
<!-- ... -->
<Button v-if="state.data" label="Déconnexion" />
<Button v-else :href="loginUrl" label="Connexion" />
</div>
</div>
</header>
</template>Ajoutons maintenant notre mutation :
<script lang="ts" setup>
const { mutate: logout } = useLogoutUser()
</script>
<template>
<header>
<div>
<!-- ... -->
<div>
<!-- ... -->
<Button v-if="state.data" label="Déconnexion" @click="logout" />
</div>
</div>
</header>
</template>Essayez maintenant, mais l'action de déconnexion ne fonctionnera pas encore. Notre composant Button est actuellement uniquement un lien :
<template>
<a :href :class="ui">
{{ props.label }}
</a>
</template>Nous avons besoin d'un composant qui rende une ancre lorsqu'il reçoit href et un bouton lorsqu'il reçoit onClick. L'élément component intégré à Vue rend cela possible.
Commençons par autoriser onClick dans les props :
export interface ButtonProps {
href?: string
label: string
color?: ButtonVariants['color']
class?: any
onClick?: () => void
}Utilisons ensuite le composant spécial intégré component :
<template>
<component
:is="props.href ? 'a' : 'button'"
:href="props.href"
:class="ui"
@click="props.onClick"
>
{{ props.label }}
</component>
</template>La ligne importante est :is="props.href ? 'a' : 'button'" : elle choisit une ancre ou un bouton en fonction des props.
Le flux complet fonctionne maintenant :
- Les utilisateurs peuvent se connecter avec GitHub.
- Les utilisateurs peuvent se déconnecter facilement depuis le front-end.
- Les utilisateurs peuvent se reconnecter.
Conclusion
L'authentification est maintenant une fonctionnalité front-end réutilisable. L'application peut récupérer l'utilisateur courant, partager le résultat en cache entre les composants et mettre à jour l'interface de manière optimiste lors de la déconnexion.
Le système de commentaires dispose ainsi de toutes les informations nécessaires pour savoir si quelqu'un est connecté et quels contrôles afficher. Dans le dernier article, nous utiliserons la même structure Pinia Colada organisée par fonctionnalité pour lister, créer, modifier et supprimer des commentaires.
Merci de me lire ! Je m'appelle Estéban, et j'adore écrire sur le développement web et le parcours humain qui l'entoure.
Je code depuis plusieurs années maintenant, et j'apprends encore de nouvelles choses chaque jour. J'aime partager mes connaissances avec les autres, car j'aurais aimé avoir accès à des ressources aussi claires et complètes lorsque j'ai commencé à apprendre la programmation.
Si vous avez des questions ou souhaitez discuter, n'hésitez pas à commenter ci-dessous ou à me contacter sur Bluesky, X, et LinkedIn.
J'espère que vous avez apprécié cet article et appris quelque chose de nouveau. N'hésitez pas à le partager avec vos amis ou sur les réseaux sociaux, et laissez un commentaire ou une réaction ci-dessous, cela me ferait très plaisir ! Si vous souhaitez soutenir mon travail, vous pouvez me sponsoriser sur GitHub !
Discussions
Ajouter un commentaire
Vous devez être connecté pour accéder à cette fonctionnalité.